@dutodev/cli 0.6.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 +121 -99
- package/dist/{chunk-26EQC6HB.js → chunk-BPOXISIM.js} +38 -2
- package/dist/{fullscreen-7P5GBCQX.js → fullscreen-IWZCH3IP.js} +69 -76
- package/dist/index.js +1094 -198
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
C,
|
|
4
|
+
TRUNCATED_NOTE,
|
|
5
|
+
bodyNote,
|
|
3
6
|
formatBytes,
|
|
4
7
|
formatDuration,
|
|
5
8
|
formatTime,
|
|
@@ -7,7 +10,7 @@ import {
|
|
|
7
10
|
normalizeName,
|
|
8
11
|
statusColor,
|
|
9
12
|
validateName
|
|
10
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-BPOXISIM.js";
|
|
11
14
|
|
|
12
15
|
// src/ui/brand.ts
|
|
13
16
|
function colorOn(stream = process.stdout) {
|
|
@@ -17,7 +20,6 @@ function colorOn(stream = process.stdout) {
|
|
|
17
20
|
}
|
|
18
21
|
var SGR = {
|
|
19
22
|
signal: "38;2;45;212;191",
|
|
20
|
-
// #2dd4bf
|
|
21
23
|
muted: "38;2;138;143;139",
|
|
22
24
|
strong: "1",
|
|
23
25
|
ok: "38;2;74;222;128",
|
|
@@ -30,6 +32,7 @@ function paint(tone, text, stream) {
|
|
|
30
32
|
var mark = () => `${paint("signal", "\u25B0")} ${paint("strong", "duto")}`;
|
|
31
33
|
var ok = (msg) => `${paint("signal", "\u2714")} ${msg}`;
|
|
32
34
|
var fail = (msg) => `${paint("err", "\u2716", process.stderr)} ${msg}`;
|
|
35
|
+
var warn = (msg) => `${paint("warn", "\u26A0")} ${msg}`;
|
|
33
36
|
|
|
34
37
|
// src/index.ts
|
|
35
38
|
import { hostname } from "os";
|
|
@@ -48,9 +51,9 @@ var DutoApiError = class extends Error {
|
|
|
48
51
|
code;
|
|
49
52
|
};
|
|
50
53
|
var MESSAGES = {
|
|
51
|
-
unauthorized: "
|
|
52
|
-
suspended: "
|
|
53
|
-
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."
|
|
54
57
|
};
|
|
55
58
|
function createApiClient(o) {
|
|
56
59
|
const doFetch = o.fetchImpl ?? fetch;
|
|
@@ -76,7 +79,7 @@ function createApiClient(o) {
|
|
|
76
79
|
const code = data.error ?? "http_error";
|
|
77
80
|
if (res.status === 401) throw new DutoApiError(MESSAGES.unauthorized, 401, code);
|
|
78
81
|
if (res.status === 403 && code === "suspended") throw new DutoApiError(MESSAGES.suspended, 403, code);
|
|
79
|
-
throw new DutoApiError(data.message ?? `
|
|
82
|
+
throw new DutoApiError(data.message ?? `duto.dev returned HTTP ${res.status}.`, res.status, code);
|
|
80
83
|
}
|
|
81
84
|
async function json(method, path, body) {
|
|
82
85
|
const res = await call(method, path, body);
|
|
@@ -165,7 +168,7 @@ function assetFor(platform, arch) {
|
|
|
165
168
|
case "win32/x64":
|
|
166
169
|
return { asset: "cloudflared-windows-amd64.exe", kind: "exe" };
|
|
167
170
|
default:
|
|
168
|
-
throw new Error(`
|
|
171
|
+
throw new Error(`Unsupported platform: ${key}`);
|
|
169
172
|
}
|
|
170
173
|
}
|
|
171
174
|
function downloadUrl(asset, version = CLOUDFLARED_VERSION) {
|
|
@@ -180,13 +183,13 @@ async function ensureCloudflared(opts = {}) {
|
|
|
180
183
|
opts.onDownload?.();
|
|
181
184
|
mkdirSync2(binDir, { recursive: true });
|
|
182
185
|
const res = await (opts.fetchImpl ?? fetch)(downloadUrl(asset));
|
|
183
|
-
if (!res.ok) throw new Error(`
|
|
186
|
+
if (!res.ok) throw new Error(`Could not download cloudflared (HTTP ${res.status}).`);
|
|
184
187
|
const bytes = Buffer.from(await res.arrayBuffer());
|
|
185
188
|
const expected = (opts.checksums ?? CHECKSUMS)[asset];
|
|
186
189
|
const actual = createHash("sha256").update(bytes).digest("hex");
|
|
187
190
|
if (!expected || actual !== expected) {
|
|
188
191
|
rmSync(target, { force: true });
|
|
189
|
-
throw new Error("
|
|
192
|
+
throw new Error("cloudflared checksum mismatch. Try again; if it keeps failing, update duto.");
|
|
190
193
|
}
|
|
191
194
|
const tmp = mkdtempSync(join2(binDir, ".download-"));
|
|
192
195
|
try {
|
|
@@ -213,13 +216,13 @@ async function ensureCloudflaredWithProgress(write, opts = {}) {
|
|
|
213
216
|
...opts,
|
|
214
217
|
onDownload: () => {
|
|
215
218
|
downloading = true;
|
|
216
|
-
write("\u2193
|
|
219
|
+
write("\u2193 Downloading cloudflared (first run only)... ");
|
|
217
220
|
}
|
|
218
221
|
});
|
|
219
222
|
if (downloading) write("ok\n");
|
|
220
223
|
return path;
|
|
221
224
|
} catch (err) {
|
|
222
|
-
if (downloading) write("
|
|
225
|
+
if (downloading) write("failed\n");
|
|
223
226
|
throw err;
|
|
224
227
|
}
|
|
225
228
|
}
|
|
@@ -270,8 +273,8 @@ var SOURCES = {
|
|
|
270
273
|
github: { url: "https://api.github.com/meta", pick: (j) => j?.hooks }
|
|
271
274
|
};
|
|
272
275
|
function splitCidr(raw) {
|
|
273
|
-
const [ip, p, ...
|
|
274
|
-
if (!ip ||
|
|
276
|
+
const [ip, p, ...extra] = raw.split("/");
|
|
277
|
+
if (!ip || extra.length) return null;
|
|
275
278
|
const v = isIP(ip);
|
|
276
279
|
if (!v) return null;
|
|
277
280
|
const max = v === 4 ? 32 : 128;
|
|
@@ -307,9 +310,9 @@ async function fetchRanges(name, f) {
|
|
|
307
310
|
const res = await f(src.url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { accept: "application/json" } });
|
|
308
311
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
309
312
|
const list = src.pick(await res.json());
|
|
310
|
-
if (!Array.isArray(list)) throw new Error("
|
|
313
|
+
if (!Array.isArray(list)) throw new Error("unexpected format");
|
|
311
314
|
const out = list.filter((x) => typeof x === "string" && splitCidr(x) !== null);
|
|
312
|
-
if (!out.length) throw new Error("
|
|
315
|
+
if (!out.length) throw new Error("empty list");
|
|
313
316
|
return out.map((x) => x.includes("/") ? x : `${x}/${splitCidr(x).prefix}`);
|
|
314
317
|
}
|
|
315
318
|
async function resolveAllow(entries, o = {}) {
|
|
@@ -321,7 +324,8 @@ async function resolveAllow(entries, o = {}) {
|
|
|
321
324
|
const cached = readCache(cacheFile);
|
|
322
325
|
const fresh = cached && now - cached.fetchedAt >= 0 && now - cached.fetchedAt < CACHE_MS ? cached : null;
|
|
323
326
|
const lists = {};
|
|
324
|
-
|
|
327
|
+
const coversEveryName = fresh && names.every((n) => Array.isArray(fresh.lists[n]) && fresh.lists[n].length);
|
|
328
|
+
if (fresh && coversEveryName) {
|
|
325
329
|
for (const n of names) lists[n] = fresh.lists[n];
|
|
326
330
|
} else {
|
|
327
331
|
const net = {};
|
|
@@ -332,7 +336,8 @@ async function resolveAllow(entries, o = {}) {
|
|
|
332
336
|
lists[name] = ALLOW_SNAPSHOT[name];
|
|
333
337
|
}
|
|
334
338
|
}
|
|
335
|
-
|
|
339
|
+
const everyListFromNetwork = names.length > 0 && Object.keys(net).length === names.length;
|
|
340
|
+
if (everyListFromNetwork) {
|
|
336
341
|
try {
|
|
337
342
|
mkdirSync3(dirname(cacheFile), { recursive: true });
|
|
338
343
|
writeFileSync3(cacheFile, JSON.stringify({ fetchedAt: now, lists: net }));
|
|
@@ -362,7 +367,7 @@ import { dirname as dirname2, join as join4 } from "path";
|
|
|
362
367
|
import { isMap, isScalar, isSeq, LineCounter, parseDocument } from "yaml";
|
|
363
368
|
var ConfigError = class extends Error {
|
|
364
369
|
constructor(message2, line, file = CONFIG_FILES[0]) {
|
|
365
|
-
super(!file ? message2 : line ? `${file},
|
|
370
|
+
super(!file ? message2 : line ? `${file}, line ${line}: ${message2}` : `${file}: ${message2}`);
|
|
366
371
|
this.line = line;
|
|
367
372
|
this.name = "ConfigError";
|
|
368
373
|
}
|
|
@@ -372,7 +377,7 @@ var CONFIG_FILES = ["duto.yml", "duto.yaml"];
|
|
|
372
377
|
var LABEL = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?";
|
|
373
378
|
var HOST_RE = new RegExp(`^${LABEL}(?:\\.${LABEL})*(?::\\d{1,5})?$`);
|
|
374
379
|
var HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
375
|
-
var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "auth", "
|
|
380
|
+
var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "auth", "allow"];
|
|
376
381
|
var ENV_REF_RE = /\$\{([A-Z_0-9]+)\}/g;
|
|
377
382
|
function parseHost(raw) {
|
|
378
383
|
const host = raw.trim().toLowerCase();
|
|
@@ -398,11 +403,11 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
|
|
|
398
403
|
const lineAt = (offset) => offset === void 0 ? void 0 : lc.linePos(offset).line;
|
|
399
404
|
if (doc.errors.length) {
|
|
400
405
|
const e = doc.errors[0];
|
|
401
|
-
throw fail3("YAML
|
|
406
|
+
throw fail3("invalid YAML. Check the indentation and the colons.", lineAt(e.pos[0]));
|
|
402
407
|
}
|
|
403
408
|
const tunnels = isMap(doc.contents) ? doc.contents.get("tunnels", true) : void 0;
|
|
404
409
|
if (!isMap(tunnels) || tunnels.items.length === 0) {
|
|
405
|
-
throw fail3('declare
|
|
410
|
+
throw fail3('declare the tunnels under "tunnels:", one per line (e.g. web: 3000)');
|
|
406
411
|
}
|
|
407
412
|
const specs = [];
|
|
408
413
|
for (const pair of tunnels.items) {
|
|
@@ -412,40 +417,40 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
|
|
|
412
417
|
const v = validateName(name);
|
|
413
418
|
if (!v.ok) {
|
|
414
419
|
throw fail3(
|
|
415
|
-
v.reason === "reserved" ? `
|
|
420
|
+
v.reason === "reserved" ? `reserved name: "${raw}". Choose another.` : `invalid name: "${raw}" (use only lowercase letters and digits, no hyphen)`,
|
|
416
421
|
line
|
|
417
422
|
);
|
|
418
423
|
}
|
|
419
424
|
const port = portOf(pair.value);
|
|
420
|
-
if (port === null) throw fail3(`
|
|
425
|
+
if (port === null) throw fail3(`invalid port for "${raw}" (use 1\u201365535)`, line);
|
|
421
426
|
const spec = { name, port };
|
|
422
427
|
if (isMap(pair.value)) {
|
|
423
428
|
const body = pair.value;
|
|
424
429
|
for (const item of body.items) {
|
|
425
430
|
const key = isScalar(item.key) ? String(item.key.value) : "";
|
|
426
|
-
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);
|
|
427
432
|
}
|
|
428
433
|
const hostNode = body.get("host", true);
|
|
429
434
|
if (hostNode !== void 0) {
|
|
430
435
|
const host = isScalar(hostNode) && typeof hostNode.value === "string" ? parseHost(hostNode.value) : null;
|
|
431
|
-
if (!host) throw fail3(`host
|
|
436
|
+
if (!host) throw fail3(`invalid host for "${raw}": use only the domain, like myapp.test`, line);
|
|
432
437
|
spec.host = host;
|
|
433
438
|
}
|
|
434
439
|
const headersNode = body.get("headers", true);
|
|
435
440
|
if (headersNode !== void 0) {
|
|
436
|
-
if (!isMap(headersNode)) throw fail3(`headers
|
|
441
|
+
if (!isMap(headersNode)) throw fail3(`headers in "${raw}" must be a list of Name: value`, line);
|
|
437
442
|
const headers = {};
|
|
438
443
|
for (const h of headersNode.items) {
|
|
439
444
|
const hName = isScalar(h.key) ? String(h.key.value) : "";
|
|
440
445
|
const hLine = lineAt(h.key?.range?.[0]);
|
|
441
|
-
if (!HEADER_NAME_RE.test(hName)) throw fail3(`
|
|
442
|
-
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);
|
|
443
448
|
const value = h.value === null ? null : isScalar(h.value) ? h.value.value : void 0;
|
|
444
449
|
if (value !== null && !["string", "number", "boolean"].includes(typeof value)) {
|
|
445
|
-
throw fail3(`
|
|
450
|
+
throw fail3(`invalid value for header "${hName}" in "${raw}"`, hLine);
|
|
446
451
|
}
|
|
447
452
|
const str = value === null ? "" : String(value);
|
|
448
|
-
if (/[\r\n]/.test(str)) throw fail3(`
|
|
453
|
+
if (/[\r\n]/.test(str)) throw fail3(`invalid value for header "${hName}" in "${raw}"`, hLine);
|
|
449
454
|
headers[hName.toLowerCase()] = str;
|
|
450
455
|
}
|
|
451
456
|
spec.headers = headers;
|
|
@@ -453,50 +458,50 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
|
|
|
453
458
|
const rewriteNode = body.get("rewrite_redirects", true);
|
|
454
459
|
if (rewriteNode !== void 0) {
|
|
455
460
|
const value = isScalar(rewriteNode) ? rewriteNode.value : void 0;
|
|
456
|
-
if (typeof value !== "boolean") throw fail3(`rewrite_redirects
|
|
457
|
-
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);
|
|
458
463
|
if (value === false) spec.rewriteRedirects = false;
|
|
459
464
|
}
|
|
460
465
|
const authNode = body.get("auth", true);
|
|
461
466
|
if (authNode !== void 0) {
|
|
462
|
-
if (!isSeq(authNode)) throw fail3(`auth
|
|
467
|
+
if (!isSeq(authNode)) throw fail3(`auth in "${raw}" must be a list of user:password`, line);
|
|
463
468
|
const auth = [];
|
|
464
469
|
for (const item of authNode.items) {
|
|
465
470
|
const iLine = lineAt(item.range?.[0]) ?? line;
|
|
466
471
|
const text2 = isScalar(item) && typeof item.value === "string" ? item.value : null;
|
|
467
|
-
if (text2 === null) throw fail3(`
|
|
472
|
+
if (text2 === null) throw fail3(`invalid credential in "${raw}": use user:password`, iLine);
|
|
468
473
|
const expanded = text2.replace(ENV_REF_RE, (_m, name2) => {
|
|
469
474
|
const val = env[name2];
|
|
470
|
-
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);
|
|
471
476
|
return val;
|
|
472
477
|
});
|
|
473
478
|
const parsed = parseCredential(expanded);
|
|
474
479
|
if (!parsed.ok) {
|
|
475
|
-
if (parsed.reason === "format") throw fail3(`
|
|
476
|
-
if (parsed.reason === "user") throw fail3(`
|
|
477
|
-
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);
|
|
478
483
|
}
|
|
479
|
-
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);
|
|
480
485
|
auth.push(parsed.cred);
|
|
481
486
|
}
|
|
482
|
-
if (!auth.length) throw fail3(`auth
|
|
487
|
+
if (!auth.length) throw fail3(`auth in "${raw}" cannot be empty`, line);
|
|
483
488
|
spec.auth = auth;
|
|
484
489
|
}
|
|
485
|
-
const allowNode = body.get("
|
|
490
|
+
const allowNode = body.get("allow", true);
|
|
486
491
|
if (allowNode !== void 0) {
|
|
487
|
-
if (!isSeq(allowNode)) throw fail3(`
|
|
488
|
-
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);
|
|
489
494
|
const allow = [];
|
|
490
495
|
for (const item of allowNode.items) {
|
|
491
496
|
const iLine = lineAt(item.range?.[0]) ?? line;
|
|
492
497
|
const value = isScalar(item) ? String(item.value) : "";
|
|
493
|
-
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);
|
|
494
499
|
allow.push(value);
|
|
495
500
|
}
|
|
496
501
|
if (allow.length) spec.allow = allow;
|
|
497
502
|
}
|
|
498
503
|
}
|
|
499
|
-
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);
|
|
500
505
|
specs.push(spec);
|
|
501
506
|
}
|
|
502
507
|
return specs;
|
|
@@ -505,7 +510,7 @@ function findConfig(cwd) {
|
|
|
505
510
|
let dir = cwd;
|
|
506
511
|
for (; ; ) {
|
|
507
512
|
const present = CONFIG_FILES.filter((f) => existsSync3(join4(dir, f)));
|
|
508
|
-
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, "");
|
|
509
514
|
if (present.length === 1) {
|
|
510
515
|
const path = join4(dir, present[0]);
|
|
511
516
|
return { path, tunnels: parseConfig(readFileSync3(path, "utf8"), present[0]) };
|
|
@@ -517,6 +522,914 @@ function findConfig(cwd) {
|
|
|
517
522
|
}
|
|
518
523
|
}
|
|
519
524
|
|
|
525
|
+
// src/inspector/jsonView.ts
|
|
526
|
+
function countLabel(n, one, many) {
|
|
527
|
+
return n + " " + (n === 1 ? one : many);
|
|
528
|
+
}
|
|
529
|
+
function jsonSyntaxError(text) {
|
|
530
|
+
try {
|
|
531
|
+
JSON.parse(text);
|
|
532
|
+
return null;
|
|
533
|
+
} catch (e) {
|
|
534
|
+
const message2 = String(e && e.message ? e.message : e);
|
|
535
|
+
const m = /position (\d+)/.exec(message2);
|
|
536
|
+
if (m) return { position: Number(m[1]), message: message2 };
|
|
537
|
+
const lc = /line (\d+) column (\d+)/.exec(message2);
|
|
538
|
+
if (lc) {
|
|
539
|
+
let idx = 0;
|
|
540
|
+
for (let ln = 1; ln < Number(lc[1]); ln++) {
|
|
541
|
+
const nl = text.indexOf("\n", idx);
|
|
542
|
+
if (nl < 0) return { position: text.length, message: message2 };
|
|
543
|
+
idx = nl + 1;
|
|
544
|
+
}
|
|
545
|
+
return { position: Math.min(idx + Number(lc[2]) - 1, text.length), message: message2 };
|
|
546
|
+
}
|
|
547
|
+
return { position: text.length, message: message2 };
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
function jsonTokens(text) {
|
|
551
|
+
const out = [];
|
|
552
|
+
let depth = 0;
|
|
553
|
+
let i = 0;
|
|
554
|
+
while (i < text.length) {
|
|
555
|
+
const c = text.charAt(i);
|
|
556
|
+
let j = i + 1;
|
|
557
|
+
let kind;
|
|
558
|
+
if (c === " " || c === "\n" || c === " " || c === "\r") {
|
|
559
|
+
while (j < text.length && " \n \r".indexOf(text.charAt(j)) >= 0) j++;
|
|
560
|
+
kind = "space";
|
|
561
|
+
} else if (c === '"') {
|
|
562
|
+
while (j < text.length && text.charAt(j) !== '"') j += text.charAt(j) === "\\" ? 2 : 1;
|
|
563
|
+
j = Math.min(j + 1, text.length);
|
|
564
|
+
let k = j;
|
|
565
|
+
while (k < text.length && " \n \r".indexOf(text.charAt(k)) >= 0) k++;
|
|
566
|
+
kind = text.charAt(k) === ":" ? "key" : "string";
|
|
567
|
+
} else if ("{}[],:".indexOf(c) >= 0) {
|
|
568
|
+
kind = "punct";
|
|
569
|
+
if (c === "}" || c === "]") depth = Math.max(0, depth - 1);
|
|
570
|
+
} else {
|
|
571
|
+
while (j < text.length && ' \n \r{}[],:"'.indexOf(text.charAt(j)) < 0) j++;
|
|
572
|
+
const word = text.slice(i, j);
|
|
573
|
+
kind = word === "true" || word === "false" || word === "null" ? "literal" : "number";
|
|
574
|
+
}
|
|
575
|
+
out.push({ kind, text: text.slice(i, j), depth });
|
|
576
|
+
if (kind === "punct" && (c === "{" || c === "[")) depth++;
|
|
577
|
+
i = j;
|
|
578
|
+
}
|
|
579
|
+
return out;
|
|
580
|
+
}
|
|
581
|
+
function jsonIndent(text) {
|
|
582
|
+
let out = "";
|
|
583
|
+
let depth = 0;
|
|
584
|
+
let i = 0;
|
|
585
|
+
const ws = " \n \r";
|
|
586
|
+
while (i < text.length) {
|
|
587
|
+
const c = text.charAt(i);
|
|
588
|
+
if (ws.indexOf(c) >= 0) {
|
|
589
|
+
i++;
|
|
590
|
+
} else if (c === '"') {
|
|
591
|
+
let j = i + 1;
|
|
592
|
+
while (j < text.length && text.charAt(j) !== '"') j += text.charAt(j) === "\\" ? 2 : 1;
|
|
593
|
+
out += text.slice(i, j + 1);
|
|
594
|
+
i = j + 1;
|
|
595
|
+
} else if (c === "{" || c === "[") {
|
|
596
|
+
let j = i + 1;
|
|
597
|
+
while (j < text.length && ws.indexOf(text.charAt(j)) >= 0) j++;
|
|
598
|
+
if (text.charAt(j) === (c === "{" ? "}" : "]")) {
|
|
599
|
+
out += c + text.charAt(j);
|
|
600
|
+
i = j + 1;
|
|
601
|
+
} else {
|
|
602
|
+
depth++;
|
|
603
|
+
out += c + "\n" + " ".repeat(depth);
|
|
604
|
+
i++;
|
|
605
|
+
}
|
|
606
|
+
} else if (c === "}" || c === "]") {
|
|
607
|
+
depth = Math.max(0, depth - 1);
|
|
608
|
+
out += "\n" + " ".repeat(depth) + c;
|
|
609
|
+
i++;
|
|
610
|
+
} else if (c === ",") {
|
|
611
|
+
out += ",\n" + " ".repeat(depth);
|
|
612
|
+
i++;
|
|
613
|
+
} else if (c === ":") {
|
|
614
|
+
out += ": ";
|
|
615
|
+
i++;
|
|
616
|
+
} else {
|
|
617
|
+
let j = i + 1;
|
|
618
|
+
while (j < text.length && (ws + '{}[],:"').indexOf(text.charAt(j)) < 0) j++;
|
|
619
|
+
out += text.slice(i, j);
|
|
620
|
+
i = j;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return out;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// src/inspector/page.ts
|
|
627
|
+
var BG = "#111413";
|
|
628
|
+
var BAR = "#0b0d0c";
|
|
629
|
+
var LINE = "#222826";
|
|
630
|
+
var ROW_LINE = "#1a1f1d";
|
|
631
|
+
var INPUT_LINE = "#2a312e";
|
|
632
|
+
var TEXT = "#e7e9e6";
|
|
633
|
+
var SELECTED = "#16201e";
|
|
634
|
+
var JSON_KEY = "#93c5fd";
|
|
635
|
+
var JSON_STRING = "#fdba74";
|
|
636
|
+
var JSON_VALUE = "#c4b5fd";
|
|
637
|
+
var MONO = '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace';
|
|
638
|
+
var STYLE = `
|
|
639
|
+
:root { color-scheme: dark; }
|
|
640
|
+
* { box-sizing: border-box; }
|
|
641
|
+
html, body { height: 100%; }
|
|
642
|
+
body { margin: 0; background: ${BG}; color: ${TEXT}; font-family: ${MONO}; font-size: 13px; line-height: 1.45; }
|
|
643
|
+
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
|
|
644
|
+
:focus-visible { outline: 1px solid ${C.signal}; outline-offset: -1px; }
|
|
645
|
+
.app { display: flex; flex-direction: column; height: 100vh; }
|
|
646
|
+
.top { display: flex; justify-content: space-between; gap: 16px; padding: 8px 12px; background: ${BAR}; border-bottom: 1px solid ${LINE}; color: ${C.muted}; }
|
|
647
|
+
.top b { color: ${C.signal}; font-weight: 600; }
|
|
648
|
+
.tunnels { display: flex; flex-wrap: wrap; gap: 4px 14px; justify-content: flex-end; min-width: 0; }
|
|
649
|
+
.tunnels .k { color: ${C.strong}; }
|
|
650
|
+
.banner { padding: 6px 12px; background: ${BAR}; border-bottom: 1px solid ${C.err}; color: ${C.err}; }
|
|
651
|
+
.main { flex: 1; min-height: 0; display: grid; grid-template-columns: 320px 1fr; }
|
|
652
|
+
.side { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid ${LINE}; }
|
|
653
|
+
.search { margin: 8px 12px; padding: 5px 8px; border: 1px solid ${INPUT_LINE}; border-radius: 5px; background: ${BG}; color: ${TEXT}; font: inherit; outline: none; }
|
|
654
|
+
.search:focus { border-color: ${C.signal}; }
|
|
655
|
+
.search::placeholder { color: ${C.muted}; }
|
|
656
|
+
.list { flex: 1; min-height: 0; overflow-y: auto; outline: none; }
|
|
657
|
+
.row { display: grid; grid-template-columns: 14px 42px minmax(0, 1fr) 34px 56px; gap: 8px; padding: 5px 12px; border-bottom: 1px solid ${ROW_LINE}; align-items: center; cursor: pointer; white-space: nowrap; }
|
|
658
|
+
.list.multi .row { grid-template-columns: 14px 56px 42px minmax(0, 1fr) 34px 56px; }
|
|
659
|
+
.row:hover { background: #141917; }
|
|
660
|
+
.row.sel { background: ${SELECTED}; box-shadow: inset 2px 0 0 ${C.signal}; }
|
|
661
|
+
.row span { overflow: hidden; text-overflow: ellipsis; }
|
|
662
|
+
.row .dur { text-align: right; }
|
|
663
|
+
.m { font-weight: 700; }
|
|
664
|
+
.mut { color: ${C.muted}; }
|
|
665
|
+
.empty { padding: 12px; color: ${C.muted}; }
|
|
666
|
+
.detail { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
|
|
667
|
+
.pane { flex: 1; }
|
|
668
|
+
.head { padding: 8px 12px; overflow-wrap: anywhere; }
|
|
669
|
+
.head b { color: ${C.strong}; }
|
|
670
|
+
.tabs { display: flex; gap: 14px; padding: 8px 12px 0; border-bottom: 1px solid ${LINE}; }
|
|
671
|
+
.tab { color: ${C.muted}; padding-bottom: 6px; border-bottom: 2px solid transparent; }
|
|
672
|
+
.tab:hover { color: ${TEXT}; }
|
|
673
|
+
.tab.on { color: ${C.strong}; border-bottom-color: ${C.signal}; }
|
|
674
|
+
.content { flex: 1; min-height: 0; overflow: auto; }
|
|
675
|
+
.content pre { margin: 0; padding: 10px 12px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; }
|
|
676
|
+
.note { padding: 10px 12px; color: ${C.muted}; }
|
|
677
|
+
.trunc { padding: 0 12px 10px; color: ${C.warn}; }
|
|
678
|
+
.jerr { padding: 10px 12px 0; color: ${C.err}; }
|
|
679
|
+
.bad { background: ${C.err}40; color: ${C.strong}; }
|
|
680
|
+
.k { color: ${JSON_KEY}; } .s { color: ${JSON_STRING}; } .n { color: ${JSON_VALUE}; }
|
|
681
|
+
.fold.closed > .inner { display: none; }
|
|
682
|
+
.fold > .sum { display: none; color: ${C.muted}; }
|
|
683
|
+
.fold.closed > .sum { display: inline; }
|
|
684
|
+
.open { cursor: pointer; }
|
|
685
|
+
.open:hover { color: ${C.signal}; }
|
|
686
|
+
table { border-collapse: collapse; margin: 6px 12px 10px; }
|
|
687
|
+
th, td { text-align: left; vertical-align: top; padding: 3px 16px 3px 0; border-bottom: 1px solid ${ROW_LINE}; overflow-wrap: anywhere; }
|
|
688
|
+
th { color: ${JSON_KEY}; font-weight: 400; white-space: nowrap; }
|
|
689
|
+
.sect { padding: 10px 12px 0; color: ${C.muted}; }
|
|
690
|
+
.btns { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 8px 12px; border-top: 1px solid ${LINE}; }
|
|
691
|
+
.btn { border: 1px solid ${INPUT_LINE}; border-radius: 5px; padding: 3px 8px; }
|
|
692
|
+
.btn:hover:not(:disabled) { border-color: ${C.muted}; }
|
|
693
|
+
.btn.pri { border-color: ${C.signal}; color: ${C.signal}; }
|
|
694
|
+
.btn:disabled { color: ${C.muted}; border-color: ${ROW_LINE}; cursor: default; }
|
|
695
|
+
.msg { color: ${C.muted}; }
|
|
696
|
+
.msg.err { color: ${C.err}; }
|
|
697
|
+
[hidden] { display: none !important; }
|
|
698
|
+
@media (max-width: 720px) {
|
|
699
|
+
.main { grid-template-columns: 1fr; grid-template-rows: 40vh 1fr; }
|
|
700
|
+
.side { border-right: 0; border-bottom: 1px solid ${LINE}; }
|
|
701
|
+
}
|
|
702
|
+
`;
|
|
703
|
+
var SCRIPT = String.raw`"use strict";
|
|
704
|
+
var countLabel = (${countLabel.toString()});
|
|
705
|
+
var jsonSyntaxError = (${jsonSyntaxError.toString()});
|
|
706
|
+
var jsonTokens = (${jsonTokens.toString()});
|
|
707
|
+
var jsonIndent = (${jsonIndent.toString()});
|
|
708
|
+
var COLORS = ${JSON.stringify(C)};
|
|
709
|
+
var TOKEN = new URLSearchParams(location.search).get("t") || "";
|
|
710
|
+
var TABS = ["Body", "Headers", "Response", "Raw"];
|
|
711
|
+
|
|
712
|
+
var state = { items: [], tunnels: [], selected: null, detail: null, detailKey: "", tab: "Body", ended: false };
|
|
713
|
+
var listSeq = 0, detailSeq = 0, msgTimer = null;
|
|
714
|
+
var es = null, lost = false, replaying = false;
|
|
715
|
+
function fail(e) { if (!state.ended) console.error(e); }
|
|
716
|
+
|
|
717
|
+
function $(id) { return document.getElementById(id); }
|
|
718
|
+
function el(tag, cls, text) {
|
|
719
|
+
var e = document.createElement(tag);
|
|
720
|
+
if (cls) e.className = cls;
|
|
721
|
+
if (text !== undefined && text !== null) e.textContent = String(text);
|
|
722
|
+
return e;
|
|
723
|
+
}
|
|
724
|
+
function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); }
|
|
725
|
+
|
|
726
|
+
function banner(text) {
|
|
727
|
+
var b = $("banner");
|
|
728
|
+
b.textContent = text || "";
|
|
729
|
+
b.hidden = !text;
|
|
730
|
+
}
|
|
731
|
+
function ended() {
|
|
732
|
+
state.ended = true;
|
|
733
|
+
banner("Session ended. Press i in duto to open the inspector again.");
|
|
734
|
+
if (es) es.close();
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function api(path, method) {
|
|
738
|
+
return fetch(path, { method: method || "GET", headers: { "X-Duto-Inspector": TOKEN }, cache: "no-store" }).then(function (r) {
|
|
739
|
+
if (r.status === 403) { ended(); throw new Error("403"); }
|
|
740
|
+
return r.json().then(function (body) { return { status: r.status, body: body }; });
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function statusInfo(it) {
|
|
745
|
+
if (it.status !== null && it.status !== undefined) {
|
|
746
|
+
var s = it.status;
|
|
747
|
+
var color = s >= 500 ? COLORS.err : s >= 400 ? COLORS.warn : s >= 300 ? COLORS.signal : COLORS.ok;
|
|
748
|
+
return { text: String(s), color: color };
|
|
749
|
+
}
|
|
750
|
+
if (it.error) return { text: "error", color: COLORS.err };
|
|
751
|
+
return { text: "…", color: COLORS.muted };
|
|
752
|
+
}
|
|
753
|
+
function duration(it) { return it.durationMs === null || it.durationMs === undefined ? "" : it.durationMs + " ms"; }
|
|
754
|
+
function size(n) {
|
|
755
|
+
if (n < 1024) return n + " B";
|
|
756
|
+
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
|
757
|
+
return (n / 1024 / 1024).toFixed(1) + " MB";
|
|
758
|
+
}
|
|
759
|
+
function methodOf(it) { return it.websocket ? "WS" : it.method; }
|
|
760
|
+
function header(h, name) {
|
|
761
|
+
for (var k in h) if (k.toLowerCase() === name) return h[k];
|
|
762
|
+
return "";
|
|
763
|
+
}
|
|
764
|
+
function visible() {
|
|
765
|
+
var f = $("filter").value.trim().toLowerCase();
|
|
766
|
+
if (!f) return state.items;
|
|
767
|
+
return state.items.filter(function (it) {
|
|
768
|
+
return (it.tunnel + " " + (it.websocket ? "ws" : it.method) + " " + it.path + " " + (it.status === null || it.status === undefined ? "" : it.status)).toLowerCase().indexOf(f) >= 0;
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function renderTop() {
|
|
773
|
+
var n = state.items.length;
|
|
774
|
+
$("count").textContent = countLabel(n, "request", "requests");
|
|
775
|
+
var box = $("tunnels");
|
|
776
|
+
clear(box);
|
|
777
|
+
state.tunnels.forEach(function (t) {
|
|
778
|
+
var s = el("span");
|
|
779
|
+
s.appendChild(el("span", "k", t.key));
|
|
780
|
+
s.appendChild(document.createTextNode(" " + (t.url || "no URL yet")));
|
|
781
|
+
box.appendChild(s);
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function renderList() {
|
|
786
|
+
var list = $("list");
|
|
787
|
+
var multi = state.tunnels.length > 1;
|
|
788
|
+
list.classList.toggle("multi", multi);
|
|
789
|
+
clear(list);
|
|
790
|
+
var items = visible();
|
|
791
|
+
if (items.length === 0) {
|
|
792
|
+
list.appendChild(el("div", "empty", state.items.length === 0 ? "No requests yet." : "No requests match the filter."));
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
items.forEach(function (it) {
|
|
796
|
+
var row = el("div", "row" + (it.id === state.selected ? " sel" : ""));
|
|
797
|
+
row.setAttribute("role", "option");
|
|
798
|
+
row.setAttribute("aria-selected", it.id === state.selected ? "true" : "false");
|
|
799
|
+
row.dataset.id = String(it.id);
|
|
800
|
+
row.appendChild(el("span", "mut", it.replayOf !== undefined ? "↻" : ""));
|
|
801
|
+
if (multi) row.appendChild(el("span", "mut", it.tunnel));
|
|
802
|
+
row.appendChild(el("span", "m", methodOf(it)));
|
|
803
|
+
var p = el("span", "", it.path);
|
|
804
|
+
p.title = it.path;
|
|
805
|
+
row.appendChild(p);
|
|
806
|
+
var st = statusInfo(it);
|
|
807
|
+
var s = el("span", "", st.text);
|
|
808
|
+
s.style.color = st.color;
|
|
809
|
+
row.appendChild(s);
|
|
810
|
+
row.appendChild(el("span", "mut dur", duration(it)));
|
|
811
|
+
row.addEventListener("click", function () { select(it.id); });
|
|
812
|
+
list.appendChild(row);
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function loadList() {
|
|
817
|
+
var seq = ++listSeq;
|
|
818
|
+
return Promise.all([api("/api/requests"), api("/api/session")]).then(function (rs) {
|
|
819
|
+
if (seq !== listSeq) return;
|
|
820
|
+
state.items = rs[0].body.items || [];
|
|
821
|
+
state.tunnels = rs[1].body.tunnels || [];
|
|
822
|
+
renderTop();
|
|
823
|
+
if (state.selected === null && state.items.length > 0) return select(state.items[0].id);
|
|
824
|
+
renderList();
|
|
825
|
+
}).catch(fail);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function select(id) {
|
|
829
|
+
if (state.selected !== id) {
|
|
830
|
+
state.selected = id;
|
|
831
|
+
state.detail = null;
|
|
832
|
+
state.detailKey = "";
|
|
833
|
+
hideMsg();
|
|
834
|
+
}
|
|
835
|
+
renderList();
|
|
836
|
+
var row = document.querySelector('.row[data-id="' + id + '"]');
|
|
837
|
+
if (row) row.scrollIntoView({ block: "nearest" });
|
|
838
|
+
return loadDetail(id);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function loadDetail(id) {
|
|
842
|
+
var seq = ++detailSeq;
|
|
843
|
+
return api("/api/requests/" + id).then(function (r) {
|
|
844
|
+
if (seq !== detailSeq || state.selected !== id) return;
|
|
845
|
+
var key = JSON.stringify(r.body);
|
|
846
|
+
if (key === state.detailKey) return;
|
|
847
|
+
state.detailKey = key;
|
|
848
|
+
state.detail = r.status === 200 ? r.body : null;
|
|
849
|
+
if (r.status !== 200) renderGone(r.body && r.body.error ? r.body.error : "That request is no longer in the list.");
|
|
850
|
+
else renderDetail();
|
|
851
|
+
}).catch(fail);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function renderGone(text) {
|
|
855
|
+
$("detail").hidden = true;
|
|
856
|
+
$("none").hidden = false;
|
|
857
|
+
$("none").textContent = text;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function renderDetail() {
|
|
861
|
+
var d = state.detail;
|
|
862
|
+
if (!d) return;
|
|
863
|
+
$("none").hidden = true;
|
|
864
|
+
$("detail").hidden = false;
|
|
865
|
+
|
|
866
|
+
var head = $("head");
|
|
867
|
+
clear(head);
|
|
868
|
+
head.appendChild(el("b", "", methodOf(d) + " " + d.path));
|
|
869
|
+
head.appendChild(document.createTextNode(" "));
|
|
870
|
+
var st = statusInfo(d);
|
|
871
|
+
var s = el("span", "", st.text);
|
|
872
|
+
s.style.color = st.color;
|
|
873
|
+
head.appendChild(s);
|
|
874
|
+
var parts = [];
|
|
875
|
+
if (duration(d)) parts.push(duration(d));
|
|
876
|
+
parts.push(size(d.bytes));
|
|
877
|
+
parts.push(d.tunnel);
|
|
878
|
+
if (d.replayOf !== undefined) parts.push("↻ replay of #" + d.replayOf);
|
|
879
|
+
head.appendChild(el("span", "mut", " · " + parts.join(" · ")));
|
|
880
|
+
if (d.error) head.appendChild(el("div", "msg err", d.error));
|
|
881
|
+
|
|
882
|
+
var tabs = document.querySelectorAll(".tab");
|
|
883
|
+
for (var i = 0; i < tabs.length; i++) {
|
|
884
|
+
var on = tabs[i].dataset.tab === state.tab;
|
|
885
|
+
tabs[i].classList.toggle("on", on);
|
|
886
|
+
tabs[i].setAttribute("aria-selected", on ? "true" : "false");
|
|
887
|
+
}
|
|
888
|
+
renderContent();
|
|
889
|
+
renderButtons();
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function renderContent() {
|
|
893
|
+
var d = state.detail;
|
|
894
|
+
var box = $("content");
|
|
895
|
+
var top = box.scrollTop;
|
|
896
|
+
clear(box);
|
|
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"));
|
|
901
|
+
box.appendChild(table(d.resHeaders));
|
|
902
|
+
box.appendChild(el("div", "sect", "Body"));
|
|
903
|
+
renderBody(box, d.resBody, d.resNote, d.resTruncatedNote, d.resHeaders);
|
|
904
|
+
} else {
|
|
905
|
+
var lines = [d.method + " " + d.path + " HTTP/1.1"];
|
|
906
|
+
for (var k in d.reqHeaders) lines.push(k + ": " + d.reqHeaders[k]);
|
|
907
|
+
lines.push("");
|
|
908
|
+
lines.push(d.reqNote !== null ? d.reqNote : d.reqBody || "");
|
|
909
|
+
box.appendChild(el("pre", "", lines.join("\n")));
|
|
910
|
+
}
|
|
911
|
+
box.scrollTop = top;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function table(h) {
|
|
915
|
+
var keys = Object.keys(h || {});
|
|
916
|
+
if (keys.length === 0) return el("div", "note", "No headers.");
|
|
917
|
+
var t = el("table");
|
|
918
|
+
keys.forEach(function (k) {
|
|
919
|
+
var tr = el("tr");
|
|
920
|
+
tr.appendChild(el("th", "", k));
|
|
921
|
+
tr.appendChild(el("td", "", h[k]));
|
|
922
|
+
t.appendChild(tr);
|
|
923
|
+
});
|
|
924
|
+
return t;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function renderBody(box, body, note, truncNote, headers) {
|
|
928
|
+
if (note !== null) {
|
|
929
|
+
box.appendChild(el("div", "note", note));
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
if (body === null || body === undefined) {
|
|
933
|
+
box.appendChild(el("div", "note", "No body."));
|
|
934
|
+
} else if (header(headers, "content-type").toLowerCase().indexOf("json") >= 0) {
|
|
935
|
+
var err = jsonSyntaxError(body);
|
|
936
|
+
if (err === null) box.appendChild(jsonTree(jsonIndent(body)));
|
|
937
|
+
else {
|
|
938
|
+
box.appendChild(el("div", "jerr", err.message));
|
|
939
|
+
var pre = el("pre");
|
|
940
|
+
var at = Math.min(err.position, Math.max(0, body.length - 1));
|
|
941
|
+
pre.appendChild(document.createTextNode(body.slice(0, at)));
|
|
942
|
+
pre.appendChild(el("span", "bad", body.slice(at, at + 1) || " "));
|
|
943
|
+
pre.appendChild(document.createTextNode(body.slice(at + 1)));
|
|
944
|
+
box.appendChild(pre);
|
|
945
|
+
}
|
|
946
|
+
} else {
|
|
947
|
+
box.appendChild(el("pre", "", body));
|
|
948
|
+
}
|
|
949
|
+
if (truncNote !== null) box.appendChild(el("div", "trunc", truncNote));
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
var KIND = { key: "k", string: "s", number: "n", literal: "n", punct: "", space: "" };
|
|
953
|
+
function jsonTree(text) {
|
|
954
|
+
var pre = el("pre");
|
|
955
|
+
var stack = [{ node: pre }];
|
|
956
|
+
jsonTokens(text).forEach(function (t) {
|
|
957
|
+
var top = stack[stack.length - 1];
|
|
958
|
+
if (top.wrap && t.kind !== "space" && !(t.kind === "punct" && (t.text === "}" || t.text === "]"))) top.has = true;
|
|
959
|
+
if (t.kind === "punct" && (t.text === "{" || t.text === "[")) {
|
|
960
|
+
var wrap = el("span", "fold");
|
|
961
|
+
var open = el("span", "open", t.text);
|
|
962
|
+
var inner = el("span", "inner");
|
|
963
|
+
var sum = el("span", "sum");
|
|
964
|
+
wrap.appendChild(open);
|
|
965
|
+
wrap.appendChild(inner);
|
|
966
|
+
wrap.appendChild(sum);
|
|
967
|
+
top.node.appendChild(wrap);
|
|
968
|
+
open.addEventListener("click", function () { if (wrap.dataset.n) wrap.classList.toggle("closed"); });
|
|
969
|
+
stack.push({ node: inner, wrap: wrap, open: open, sum: sum, depth: t.depth, obj: t.text === "{", count: 0, has: false });
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
if (t.kind === "punct" && (t.text === "}" || t.text === "]") && stack.length > 1) {
|
|
973
|
+
var c = stack.pop();
|
|
974
|
+
var n = c.has ? c.count + 1 : 0;
|
|
975
|
+
if (n > 0) {
|
|
976
|
+
c.wrap.dataset.n = String(n);
|
|
977
|
+
c.open.title = "collapse or expand";
|
|
978
|
+
c.sum.textContent = " ▸ " + (c.obj ? countLabel(n, "field", "fields") : countLabel(n, "item", "items")) + " ";
|
|
979
|
+
} else c.open.className = "";
|
|
980
|
+
c.wrap.appendChild(document.createTextNode(t.text));
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
if (top.wrap && t.kind === "punct" && t.text === "," && t.depth === top.depth + 1) top.count++;
|
|
984
|
+
var cls = KIND[t.kind];
|
|
985
|
+
top.node.appendChild(cls ? el("span", cls, t.text) : document.createTextNode(t.text));
|
|
986
|
+
});
|
|
987
|
+
return pre;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function renderButtons() {
|
|
991
|
+
var d = state.detail;
|
|
992
|
+
var replay = $("replay");
|
|
993
|
+
replay.disabled = d.replayBlocked !== null || replaying;
|
|
994
|
+
replay.title = d.replayBlocked || "";
|
|
995
|
+
replay.parentNode.title = d.replayBlocked || "";
|
|
996
|
+
|
|
997
|
+
var copyBody = $("copyBody");
|
|
998
|
+
var b = copyable(d);
|
|
999
|
+
copyBody.disabled = b.text === null;
|
|
1000
|
+
copyBody.title = b.text === null ? b.note || "" : "";
|
|
1001
|
+
copyBody.parentNode.title = copyBody.title;
|
|
1002
|
+
|
|
1003
|
+
var curl = $("copyCurl");
|
|
1004
|
+
curl.disabled = d.curl === null;
|
|
1005
|
+
curl.title = d.curl === null ? d.curlNote || "" : "";
|
|
1006
|
+
curl.parentNode.title = curl.title;
|
|
1007
|
+
var notes = [];
|
|
1008
|
+
if (d.curl !== null && d.curlNote) notes.push(d.curlNote);
|
|
1009
|
+
var tunnel = state.tunnels.filter(function (t) { return t.key === d.tunnel; })[0];
|
|
1010
|
+
if (d.curl !== null && tunnel && tunnel.auth) notes.push("Replace PASSWORD with the tunnel password.");
|
|
1011
|
+
$("curlNote").textContent = notes.join(" ");
|
|
1012
|
+
}
|
|
1013
|
+
function copyable(d) {
|
|
1014
|
+
return state.tab === "Response" ? { text: d.resBody, note: d.resNote } : { text: d.reqBody, note: d.reqNote };
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function showMsg(text, isErr) {
|
|
1018
|
+
var m = $("msg");
|
|
1019
|
+
m.textContent = text;
|
|
1020
|
+
m.className = "msg" + (isErr ? " err" : "");
|
|
1021
|
+
m.hidden = false;
|
|
1022
|
+
if (msgTimer) clearTimeout(msgTimer);
|
|
1023
|
+
msgTimer = null;
|
|
1024
|
+
}
|
|
1025
|
+
function hideMsg() {
|
|
1026
|
+
if (msgTimer) clearTimeout(msgTimer);
|
|
1027
|
+
msgTimer = null;
|
|
1028
|
+
$("msg").hidden = true;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function copy(text) {
|
|
1032
|
+
navigator.clipboard.writeText(text).then(function () {
|
|
1033
|
+
showMsg("Copied.", false);
|
|
1034
|
+
msgTimer = setTimeout(hideMsg, 2000);
|
|
1035
|
+
}, function () {
|
|
1036
|
+
showMsg("Could not copy.", true);
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function replay() {
|
|
1041
|
+
var d = state.detail;
|
|
1042
|
+
if (!d || state.ended) return;
|
|
1043
|
+
if (d.replayBlocked !== null) return showMsg(d.replayBlocked, true);
|
|
1044
|
+
if (replaying) return;
|
|
1045
|
+
replaying = true;
|
|
1046
|
+
$("replay").disabled = true;
|
|
1047
|
+
api("/api/requests/" + d.id + "/replay", "POST").then(function (r) {
|
|
1048
|
+
replaying = false;
|
|
1049
|
+
if (state.detail) renderButtons();
|
|
1050
|
+
if (r.body && r.body.ok) {
|
|
1051
|
+
hideMsg();
|
|
1052
|
+
return loadList().then(function () { return select(r.body.id); });
|
|
1053
|
+
}
|
|
1054
|
+
showMsg(r.body && r.body.reason ? r.body.reason : "Could not replay.", true);
|
|
1055
|
+
}).catch(function (e) {
|
|
1056
|
+
replaying = false;
|
|
1057
|
+
if (state.detail) renderButtons();
|
|
1058
|
+
fail(e);
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function move(delta) {
|
|
1063
|
+
var items = visible();
|
|
1064
|
+
if (items.length === 0) return;
|
|
1065
|
+
var i = -1;
|
|
1066
|
+
for (var j = 0; j < items.length; j++) if (items[j].id === state.selected) i = j;
|
|
1067
|
+
var next = i < 0 ? 0 : Math.max(0, Math.min(items.length - 1, i + delta));
|
|
1068
|
+
if (items[next].id !== state.selected) select(items[next].id);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function connect() {
|
|
1072
|
+
if (state.ended) return;
|
|
1073
|
+
es = new EventSource("/api/events?t=" + encodeURIComponent(TOKEN));
|
|
1074
|
+
es.addEventListener("open", function () {
|
|
1075
|
+
if (state.ended) return;
|
|
1076
|
+
banner("");
|
|
1077
|
+
if (lost) {
|
|
1078
|
+
lost = false;
|
|
1079
|
+
loadList();
|
|
1080
|
+
if (state.selected !== null) loadDetail(state.selected);
|
|
1081
|
+
}
|
|
1082
|
+
});
|
|
1083
|
+
es.addEventListener("error", function () {
|
|
1084
|
+
if (state.ended) return;
|
|
1085
|
+
lost = true;
|
|
1086
|
+
banner("Disconnected from the session.");
|
|
1087
|
+
if (es.readyState === 2) probe();
|
|
1088
|
+
});
|
|
1089
|
+
es.addEventListener("changed", function (ev) {
|
|
1090
|
+
var ids = [];
|
|
1091
|
+
try { ids = JSON.parse(ev.data).ids || []; } catch (e) {}
|
|
1092
|
+
loadList();
|
|
1093
|
+
if (state.selected !== null && (ids.length === 0 || ids.indexOf(state.selected) >= 0)) loadDetail(state.selected);
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
function probe() {
|
|
1097
|
+
api("/api/session").then(function () {
|
|
1098
|
+
if (!state.ended) setTimeout(connect, 3000);
|
|
1099
|
+
}, function () {
|
|
1100
|
+
if (!state.ended) setTimeout(connect, 3000);
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function init() {
|
|
1105
|
+
var tabs = document.querySelectorAll(".tab");
|
|
1106
|
+
for (var i = 0; i < tabs.length; i++) {
|
|
1107
|
+
tabs[i].addEventListener("click", function (ev) {
|
|
1108
|
+
state.tab = ev.currentTarget.dataset.tab;
|
|
1109
|
+
if (state.detail) renderDetail();
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
$("filter").addEventListener("input", renderList);
|
|
1113
|
+
$("replay").addEventListener("click", replay);
|
|
1114
|
+
$("copyBody").addEventListener("click", function () {
|
|
1115
|
+
var b = state.detail ? copyable(state.detail) : { text: null };
|
|
1116
|
+
if (b.text !== null && b.text !== undefined) copy(b.text);
|
|
1117
|
+
});
|
|
1118
|
+
$("copyCurl").addEventListener("click", function () { if (state.detail && state.detail.curl !== null) copy(state.detail.curl); });
|
|
1119
|
+
|
|
1120
|
+
document.addEventListener("keydown", function (ev) {
|
|
1121
|
+
if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
|
|
1122
|
+
var inFilter = document.activeElement === $("filter");
|
|
1123
|
+
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
|
|
1124
|
+
ev.preventDefault();
|
|
1125
|
+
move(ev.key === "ArrowDown" ? 1 : -1);
|
|
1126
|
+
} else if (ev.key === "Escape") {
|
|
1127
|
+
if ($("filter").value) {
|
|
1128
|
+
$("filter").value = "";
|
|
1129
|
+
renderList();
|
|
1130
|
+
}
|
|
1131
|
+
if (inFilter) $("filter").blur();
|
|
1132
|
+
} else if (ev.key === "/" && !inFilter) {
|
|
1133
|
+
ev.preventDefault();
|
|
1134
|
+
$("filter").focus();
|
|
1135
|
+
} else if ((ev.key === "r" || ev.key === "R") && !inFilter && !ev.repeat) {
|
|
1136
|
+
replay();
|
|
1137
|
+
}
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
loadList();
|
|
1141
|
+
connect();
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
init();
|
|
1145
|
+
`;
|
|
1146
|
+
function inspectorPage(nonce) {
|
|
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("");
|
|
1148
|
+
return `<!doctype html>
|
|
1149
|
+
<html lang="en">
|
|
1150
|
+
<head>
|
|
1151
|
+
<meta charset="utf-8">
|
|
1152
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1153
|
+
<title>duto \xB7 inspector</title>
|
|
1154
|
+
<link rel="icon" href="data:,">
|
|
1155
|
+
<style nonce="${nonce}">${STYLE}</style>
|
|
1156
|
+
</head>
|
|
1157
|
+
<body>
|
|
1158
|
+
<div class="app">
|
|
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>
|
|
1160
|
+
<div class="banner" id="banner" role="status" hidden></div>
|
|
1161
|
+
<div class="main">
|
|
1162
|
+
<div class="side">
|
|
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>
|
|
1165
|
+
</div>
|
|
1166
|
+
<div class="detail">
|
|
1167
|
+
<div class="empty" id="none">Select a request from the list.</div>
|
|
1168
|
+
<div class="detail pane" id="detail" hidden>
|
|
1169
|
+
<div class="head" id="head"></div>
|
|
1170
|
+
<div class="tabs" role="tablist">${tabs}</div>
|
|
1171
|
+
<div class="content" id="content"></div>
|
|
1172
|
+
<div class="btns">
|
|
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>
|
|
1176
|
+
<span class="msg" id="curlNote"></span>
|
|
1177
|
+
<span class="msg" id="msg" role="status" hidden></span>
|
|
1178
|
+
</div>
|
|
1179
|
+
</div>
|
|
1180
|
+
</div>
|
|
1181
|
+
</div>
|
|
1182
|
+
</div>
|
|
1183
|
+
<script nonce="${nonce}">${SCRIPT}</script>
|
|
1184
|
+
</body>
|
|
1185
|
+
</html>
|
|
1186
|
+
`;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// src/inspector/server.ts
|
|
1190
|
+
import { createHash as createHash2, randomBytes, timingSafeEqual } from "crypto";
|
|
1191
|
+
import { createServer } from "http";
|
|
1192
|
+
|
|
1193
|
+
// src/session/replay.ts
|
|
1194
|
+
var tunnelClosedReason = (tunnel2) => `Tunnel ${tunnel2} is not open in this session.`;
|
|
1195
|
+
function replayBlock(r) {
|
|
1196
|
+
if (!r) return "That request is no longer in the list.";
|
|
1197
|
+
if (r.websocket) return "WebSocket requests cannot be replayed.";
|
|
1198
|
+
if (r.noReplay) return r.noReplay;
|
|
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.";
|
|
1203
|
+
return null;
|
|
1204
|
+
}
|
|
1205
|
+
var HOP_BY_HOP_AND_BODY_HEADERS = /* @__PURE__ */ new Set(["connection", "keep-alive", "transfer-encoding", "expect", "content-length", "upgrade", "te", "trailer", "proxy-connection"]);
|
|
1206
|
+
function replayHeaders(r) {
|
|
1207
|
+
const out = {};
|
|
1208
|
+
for (const [k, v] of Object.entries(r.reqHeaders)) if (!HOP_BY_HOP_AND_BODY_HEADERS.has(k.toLowerCase())) out[k] = v;
|
|
1209
|
+
const hadLength = Object.keys(r.reqHeaders).some((k) => k.toLowerCase() === "content-length");
|
|
1210
|
+
if (r.reqBodyBytes > 0 || hadLength) out["content-length"] = String(r.reqRaw?.length ?? 0);
|
|
1211
|
+
return out;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// src/inspector/curl.ts
|
|
1215
|
+
var SKIP = /* @__PURE__ */ new Set(["host", "content-length", "transfer-encoding", "connection", "keep-alive", "expect", "upgrade", "te", "trailer", "proxy-connection"]);
|
|
1216
|
+
var TUNNEL_ADDED = /* @__PURE__ */ new Set(["cdn-loop", "x-forwarded-for", "x-forwarded-proto", "x-real-ip", "true-client-ip"]);
|
|
1217
|
+
var skip = (name) => {
|
|
1218
|
+
const k = name.toLowerCase();
|
|
1219
|
+
return SKIP.has(k) || TUNNEL_ADDED.has(k) || k.startsWith("cf-");
|
|
1220
|
+
};
|
|
1221
|
+
function shellQuote(s) {
|
|
1222
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
1223
|
+
}
|
|
1224
|
+
function toCurl(r, o) {
|
|
1225
|
+
const parts = [`curl -X ${shellQuote(r.method)} ${shellQuote(o.publicUrl.replace(/\/+$/, "") + r.path)}`];
|
|
1226
|
+
for (const [k, v] of Object.entries(r.reqHeaders)) {
|
|
1227
|
+
if (!skip(k)) parts.push(`-H ${shellQuote(`${k}: ${v}`)}`);
|
|
1228
|
+
}
|
|
1229
|
+
if (o.authUser !== null) parts.push(`-H ${shellQuote(`X-Duto-Auth: ${o.authUser}:PASSWORD`)}`);
|
|
1230
|
+
if (r.reqBody !== null) parts.push(`--data-binary ${shellQuote(r.reqBody)}`);
|
|
1231
|
+
return parts.join(" \\\n ");
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// src/inspector/server.ts
|
|
1235
|
+
var DEFAULT_PORT = 4141;
|
|
1236
|
+
var PORT_TRIES = 20;
|
|
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).";
|
|
1241
|
+
var sha = (s) => createHash2("sha256").update(s).digest();
|
|
1242
|
+
var constantTimeEqual = (a, b) => timingSafeEqual(sha(a), sha(b));
|
|
1243
|
+
var first = (v) => Array.isArray(v) ? v[0] : v;
|
|
1244
|
+
function listItem(r) {
|
|
1245
|
+
return {
|
|
1246
|
+
id: r.id,
|
|
1247
|
+
tunnel: r.tunnel,
|
|
1248
|
+
method: r.method,
|
|
1249
|
+
path: r.path,
|
|
1250
|
+
status: r.status,
|
|
1251
|
+
durationMs: r.durationMs,
|
|
1252
|
+
bytes: r.bytes,
|
|
1253
|
+
startedAt: r.startedAt,
|
|
1254
|
+
websocket: r.websocket,
|
|
1255
|
+
...r.replayOf !== void 0 && { replayOf: r.replayOf },
|
|
1256
|
+
...r.error !== void 0 && { error: r.error }
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
function detail(r, session) {
|
|
1260
|
+
const tunnel2 = session.getState().tunnels.find((t) => t.key === r.tunnel);
|
|
1261
|
+
const tunnelOpen = !!tunnel2 && (tunnel2.status === "live" || tunnel2.status === "no-listener");
|
|
1262
|
+
let curl = null;
|
|
1263
|
+
let curlNote = null;
|
|
1264
|
+
if (!tunnel2?.url) curlNote = NO_URL;
|
|
1265
|
+
else {
|
|
1266
|
+
curl = toCurl(r, { publicUrl: tunnel2.url, authUser: session.authUser(r.tunnel) });
|
|
1267
|
+
if (r.reqRaw && !Buffer.from(r.reqBody ?? "", "utf8").equals(r.reqRaw)) curlNote = CURL_BYTES;
|
|
1268
|
+
}
|
|
1269
|
+
return {
|
|
1270
|
+
...listItem(r),
|
|
1271
|
+
reqHeaders: r.reqHeaders,
|
|
1272
|
+
resHeaders: r.resHeaders,
|
|
1273
|
+
reqBody: r.reqBody,
|
|
1274
|
+
resBody: r.resBody,
|
|
1275
|
+
reqNote: bodyNote(r, "req"),
|
|
1276
|
+
resNote: bodyNote(r, "res"),
|
|
1277
|
+
reqTruncatedNote: r.reqTruncated ? TRUNCATED_NOTE : null,
|
|
1278
|
+
resTruncatedNote: r.resTruncated ? TRUNCATED_NOTE : null,
|
|
1279
|
+
replayBlocked: replayBlock(r) ?? (tunnelOpen ? null : tunnelClosedReason(r.tunnel)),
|
|
1280
|
+
curl,
|
|
1281
|
+
curlNote
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
var sig = (r) => `${r.status}|${r.durationMs}|${r.bytes}|${r.resBody?.length ?? -1}|${r.reqComplete}|${r.error ?? ""}|${Object.keys(r.resHeaders).length > 0}`;
|
|
1285
|
+
async function startInspector(o) {
|
|
1286
|
+
const { session } = o;
|
|
1287
|
+
const eventMs = o.eventMs ?? 150;
|
|
1288
|
+
const pingMs = o.pingMs ?? 25e3;
|
|
1289
|
+
const token = randomBytes(24).toString("base64url");
|
|
1290
|
+
let port = 0;
|
|
1291
|
+
const clients = /* @__PURE__ */ new Set();
|
|
1292
|
+
let seen = /* @__PURE__ */ new Map();
|
|
1293
|
+
let timer = null;
|
|
1294
|
+
const prime = () => seen = new Map(session.records.list().map((r) => [r.id, sig(r)]));
|
|
1295
|
+
const flush = () => {
|
|
1296
|
+
timer = null;
|
|
1297
|
+
const next = /* @__PURE__ */ new Map();
|
|
1298
|
+
const ids = [];
|
|
1299
|
+
for (const r of session.records.list()) {
|
|
1300
|
+
const s = sig(r);
|
|
1301
|
+
next.set(r.id, s);
|
|
1302
|
+
if (seen.get(r.id) !== s) ids.push(r.id);
|
|
1303
|
+
}
|
|
1304
|
+
seen = next;
|
|
1305
|
+
const msg = `event: changed
|
|
1306
|
+
data: ${JSON.stringify({ ids })}
|
|
1307
|
+
|
|
1308
|
+
`;
|
|
1309
|
+
for (const c of clients) c.write(msg);
|
|
1310
|
+
};
|
|
1311
|
+
const schedule = () => {
|
|
1312
|
+
if (clients.size > 0 && !timer) timer = setTimeout(flush, eventMs);
|
|
1313
|
+
};
|
|
1314
|
+
const unsubs = [session.records.subscribe(schedule), session.subscribe(schedule)];
|
|
1315
|
+
const pinger = setInterval(() => {
|
|
1316
|
+
for (const c of clients) c.write(": ping\n\n");
|
|
1317
|
+
}, pingMs);
|
|
1318
|
+
pinger.unref();
|
|
1319
|
+
const base = (res) => {
|
|
1320
|
+
res.setHeader("Cache-Control", "no-store");
|
|
1321
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1322
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
1323
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
1324
|
+
};
|
|
1325
|
+
const text = (res, status, body) => {
|
|
1326
|
+
res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" });
|
|
1327
|
+
res.end(body);
|
|
1328
|
+
};
|
|
1329
|
+
const json = (res, status, body) => {
|
|
1330
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
1331
|
+
res.end(JSON.stringify(body));
|
|
1332
|
+
};
|
|
1333
|
+
const handle = (req, res) => {
|
|
1334
|
+
base(res);
|
|
1335
|
+
const host = first(req.headers.host)?.toLowerCase();
|
|
1336
|
+
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return text(res, 403, "Host not allowed.");
|
|
1337
|
+
let url2;
|
|
1338
|
+
try {
|
|
1339
|
+
url2 = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
1340
|
+
} catch {
|
|
1341
|
+
return text(res, 404, "Not found.");
|
|
1342
|
+
}
|
|
1343
|
+
const path = url2.pathname;
|
|
1344
|
+
const method = req.method ?? "GET";
|
|
1345
|
+
const queryToken = url2.searchParams.get("t") ?? "";
|
|
1346
|
+
const headerToken = first(req.headers["x-duto-inspector"]) ?? "";
|
|
1347
|
+
if (method === "GET" && path === "/") {
|
|
1348
|
+
if (!constantTimeEqual(queryToken, token)) return text(res, 403, NO_TOKEN);
|
|
1349
|
+
const nonce = randomBytes(16).toString("base64url");
|
|
1350
|
+
res.writeHead(200, {
|
|
1351
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1352
|
+
"Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`
|
|
1353
|
+
});
|
|
1354
|
+
return void res.end(o.page(nonce));
|
|
1355
|
+
}
|
|
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 });
|
|
1361
|
+
if (isEvents) {
|
|
1362
|
+
res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", Connection: "keep-alive" });
|
|
1363
|
+
res.write(": duto\n\n");
|
|
1364
|
+
if (clients.size === 0) prime();
|
|
1365
|
+
clients.add(res);
|
|
1366
|
+
res.on("close", () => clients.delete(res));
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
if (path === "/api/session") {
|
|
1370
|
+
return json(res, 200, { tunnels: session.getState().tunnels.map((t) => ({ key: t.key, url: t.url, auth: !!t.auth })) });
|
|
1371
|
+
}
|
|
1372
|
+
if (path === "/api/requests") return json(res, 200, { items: session.records.list().map(listItem) });
|
|
1373
|
+
const id = Number(detailId[1]);
|
|
1374
|
+
if (!Number.isSafeInteger(id)) return json(res, 404, { error: GONE });
|
|
1375
|
+
if (method === "POST") return json(res, 200, session.replay(id));
|
|
1376
|
+
const r = session.records.get(id);
|
|
1377
|
+
if (!r) return json(res, 404, { error: GONE });
|
|
1378
|
+
return json(res, 200, detail(r, session));
|
|
1379
|
+
};
|
|
1380
|
+
const server = createServer((req, res) => {
|
|
1381
|
+
try {
|
|
1382
|
+
handle(req, res);
|
|
1383
|
+
} catch {
|
|
1384
|
+
if (!res.headersSent) text(res, 500, "Internal error.");
|
|
1385
|
+
else res.end();
|
|
1386
|
+
}
|
|
1387
|
+
});
|
|
1388
|
+
const listen = (p) => new Promise((resolve, reject) => {
|
|
1389
|
+
const onError = (e) => reject(e);
|
|
1390
|
+
server.once("error", onError);
|
|
1391
|
+
server.listen(p, "127.0.0.1", () => {
|
|
1392
|
+
server.off("error", onError);
|
|
1393
|
+
resolve();
|
|
1394
|
+
});
|
|
1395
|
+
});
|
|
1396
|
+
const want = o.port ?? DEFAULT_PORT;
|
|
1397
|
+
try {
|
|
1398
|
+
let bound = false;
|
|
1399
|
+
if (want !== 0) {
|
|
1400
|
+
for (let p = want; p <= want + PORT_TRIES && !bound; p++) {
|
|
1401
|
+
try {
|
|
1402
|
+
await listen(p);
|
|
1403
|
+
bound = true;
|
|
1404
|
+
} catch (e) {
|
|
1405
|
+
if (e.code !== "EADDRINUSE") throw e;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
if (!bound) await listen(0);
|
|
1410
|
+
} catch (e) {
|
|
1411
|
+
for (const u of unsubs) u();
|
|
1412
|
+
clearInterval(pinger);
|
|
1413
|
+
throw e;
|
|
1414
|
+
}
|
|
1415
|
+
port = server.address().port;
|
|
1416
|
+
const url = `http://127.0.0.1:${port}`;
|
|
1417
|
+
return {
|
|
1418
|
+
port,
|
|
1419
|
+
url,
|
|
1420
|
+
openUrl: `${url}/?t=${token}`,
|
|
1421
|
+
close: () => new Promise((resolve) => {
|
|
1422
|
+
for (const u of unsubs) u();
|
|
1423
|
+
clearInterval(pinger);
|
|
1424
|
+
if (timer) clearTimeout(timer);
|
|
1425
|
+
for (const c of clients) c.end();
|
|
1426
|
+
clients.clear();
|
|
1427
|
+
server.close(() => resolve());
|
|
1428
|
+
server.closeAllConnections();
|
|
1429
|
+
})
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
|
|
520
1433
|
// src/session/session.ts
|
|
521
1434
|
import { spawn } from "child_process";
|
|
522
1435
|
|
|
@@ -540,11 +1453,12 @@ async function isPortListening(port, timeoutMs = 500) {
|
|
|
540
1453
|
}
|
|
541
1454
|
|
|
542
1455
|
// src/session/auth.ts
|
|
543
|
-
import { createHash as
|
|
544
|
-
var TUNNEL_COOKIE = "__Host-
|
|
1456
|
+
import { createHash as createHash3, createHmac, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
1457
|
+
var TUNNEL_COOKIE = "__Host-duto_tunnel";
|
|
1458
|
+
var SAME_SITE_SIBLING_TUNNEL_COOKIE_REJECTED = "same-site";
|
|
545
1459
|
var QR_TTL_MS = 5 * 6e4;
|
|
546
|
-
var
|
|
547
|
-
var
|
|
1460
|
+
var sha2 = (s) => createHash3("sha256").update(s).digest();
|
|
1461
|
+
var constantTimeEqual2 = (a, b) => timingSafeEqual2(sha2(a), sha2(b));
|
|
548
1462
|
function splitPair(raw) {
|
|
549
1463
|
const i = raw.indexOf(":");
|
|
550
1464
|
return i < 0 ? null : { user: raw.slice(0, i), password: raw.slice(i + 1) };
|
|
@@ -553,11 +1467,11 @@ function parseBasic(value) {
|
|
|
553
1467
|
const m = /^basic +([A-Za-z0-9+/]+={0,2})$/i.exec(value?.trim() ?? "");
|
|
554
1468
|
return m ? splitPair(Buffer.from(m[1], "base64").toString("utf8")) : null;
|
|
555
1469
|
}
|
|
556
|
-
var
|
|
1470
|
+
var first2 = (v) => Array.isArray(v) ? v[0] : v;
|
|
557
1471
|
var bareHost = (s) => s.replace(/:\d+$/, "").toLowerCase();
|
|
558
1472
|
function sameHostOrigin(h) {
|
|
559
|
-
if (
|
|
560
|
-
const origin =
|
|
1473
|
+
if (first2(h["sec-fetch-site"])?.toLowerCase() === SAME_SITE_SIBLING_TUNNEL_COOKIE_REJECTED) return false;
|
|
1474
|
+
const origin = first2(h.origin);
|
|
561
1475
|
if (origin === void 0) return true;
|
|
562
1476
|
let host;
|
|
563
1477
|
try {
|
|
@@ -580,27 +1494,27 @@ var TunnelAuth = class {
|
|
|
580
1494
|
this.credentials = o.credentials;
|
|
581
1495
|
this.allow = o.allow;
|
|
582
1496
|
this.now = o.now ?? Date.now;
|
|
583
|
-
this.secret = o.secret ??
|
|
1497
|
+
this.secret = o.secret ?? randomBytes2(32);
|
|
584
1498
|
this.qrUser = o.credentials[0]?.user ?? "";
|
|
585
1499
|
}
|
|
586
1500
|
access(h) {
|
|
587
1501
|
if (this.allow) {
|
|
588
|
-
const ip =
|
|
1502
|
+
const ip = first2(h["cf-connecting-ip"]);
|
|
589
1503
|
const reason = this.allow.match(ip);
|
|
590
1504
|
if (reason) return { kind: "allow", reason };
|
|
591
1505
|
}
|
|
592
1506
|
const cookieUser = this.cookieUser(h.cookie);
|
|
593
1507
|
if (cookieUser && sameHostOrigin(h)) return { kind: "user", user: cookieUser };
|
|
594
|
-
const
|
|
595
|
-
const
|
|
596
|
-
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 };
|
|
597
1511
|
return { kind: "denied" };
|
|
598
1512
|
}
|
|
599
1513
|
checkPassword(user, password) {
|
|
600
1514
|
let ok2 = false;
|
|
601
1515
|
for (const c of this.credentials) {
|
|
602
|
-
const userOk =
|
|
603
|
-
const passOk =
|
|
1516
|
+
const userOk = constantTimeEqual2(c.user, user);
|
|
1517
|
+
const passOk = constantTimeEqual2(c.password, password);
|
|
604
1518
|
if (userOk && passOk) ok2 = true;
|
|
605
1519
|
}
|
|
606
1520
|
return ok2;
|
|
@@ -612,19 +1526,19 @@ var TunnelAuth = class {
|
|
|
612
1526
|
return `${TUNNEL_COOKIE}=; Max-Age=0; Secure; HttpOnly; SameSite=Lax; Path=/`;
|
|
613
1527
|
}
|
|
614
1528
|
issueQrToken() {
|
|
615
|
-
this.qr = { token:
|
|
1529
|
+
this.qr = { token: randomBytes2(24).toString("base64url"), expiresAt: this.now() + QR_TTL_MS };
|
|
616
1530
|
return { ...this.qr };
|
|
617
1531
|
}
|
|
618
1532
|
consumeQrToken(token) {
|
|
619
1533
|
const qr = this.qr;
|
|
620
|
-
if (!qr || !
|
|
1534
|
+
if (!qr || !constantTimeEqual2(qr.token, token)) return false;
|
|
621
1535
|
this.qr = null;
|
|
622
1536
|
return this.now() <= qr.expiresAt;
|
|
623
1537
|
}
|
|
624
1538
|
strip(h) {
|
|
625
1539
|
const out = { ...h };
|
|
626
1540
|
delete out["x-duto-auth"];
|
|
627
|
-
const basic = parseBasic(
|
|
1541
|
+
const basic = parseBasic(first2(h.authorization));
|
|
628
1542
|
if (basic && this.checkPassword(basic.user, basic.password)) delete out.authorization;
|
|
629
1543
|
if (h.cookie !== void 0) {
|
|
630
1544
|
const parts = (Array.isArray(h.cookie) ? h.cookie.join("; ") : h.cookie).split(";").map((p) => p.trim()).filter(Boolean);
|
|
@@ -635,7 +1549,6 @@ var TunnelAuth = class {
|
|
|
635
1549
|
}
|
|
636
1550
|
return out;
|
|
637
1551
|
}
|
|
638
|
-
// Valor: usuário em base64url + "." + HMAC(túnel, usuário).
|
|
639
1552
|
cookieValue(user) {
|
|
640
1553
|
return `${Buffer.from(user).toString("base64url")}.${this.sign(user)}`;
|
|
641
1554
|
}
|
|
@@ -648,10 +1561,10 @@ var TunnelAuth = class {
|
|
|
648
1561
|
for (const p of list) {
|
|
649
1562
|
const item = p.trim();
|
|
650
1563
|
if (!item.startsWith(`${TUNNEL_COOKIE}=`)) continue;
|
|
651
|
-
const [b64,
|
|
652
|
-
if (!b64 || !
|
|
1564
|
+
const [b64, sig2, ...extra] = item.slice(TUNNEL_COOKIE.length + 1).split(".");
|
|
1565
|
+
if (!b64 || !sig2 || extra.length) continue;
|
|
653
1566
|
const user = Buffer.from(b64, "base64url").toString("utf8");
|
|
654
|
-
if (
|
|
1567
|
+
if (constantTimeEqual2(sig2, this.sign(user)) && this.credentials.some((c) => c.user === user)) return user;
|
|
655
1568
|
}
|
|
656
1569
|
return null;
|
|
657
1570
|
}
|
|
@@ -682,7 +1595,7 @@ function cors401(origin) {
|
|
|
682
1595
|
}
|
|
683
1596
|
|
|
684
1597
|
// src/session/proxy.ts
|
|
685
|
-
import { Agent, createServer, request } from "http";
|
|
1598
|
+
import { Agent, createServer as createServer2, request } from "http";
|
|
686
1599
|
import { connect as connect2 } from "net";
|
|
687
1600
|
|
|
688
1601
|
// src/session/errorPage.ts
|
|
@@ -703,14 +1616,14 @@ var WAIT_SCRIPT = `
|
|
|
703
1616
|
}
|
|
704
1617
|
function again() { delay = Math.min(delay * 2, 30000); poll(); }
|
|
705
1618
|
if (!window.EventSource) return poll();
|
|
706
|
-
var fails = 0, es = new EventSource("/__duto/
|
|
1619
|
+
var fails = 0, es = new EventSource("/__duto/wait");
|
|
707
1620
|
es.addEventListener("up", function () { es.close(); location.reload(); });
|
|
708
1621
|
es.onerror = function () { if (++fails >= 3) { es.close(); poll(); } };
|
|
709
1622
|
})();
|
|
710
1623
|
</script>`;
|
|
711
1624
|
function pageShell(title, body, css = "") {
|
|
712
1625
|
return `<!doctype html>
|
|
713
|
-
<html lang="
|
|
1626
|
+
<html lang="en">
|
|
714
1627
|
<head>
|
|
715
1628
|
<meta charset="utf-8">
|
|
716
1629
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
@@ -742,13 +1655,13 @@ function unavailablePage(o) {
|
|
|
742
1655
|
const tunnel2 = esc(o.tunnel);
|
|
743
1656
|
const local = `localhost:${o.port}`;
|
|
744
1657
|
return pageShell(
|
|
745
|
-
`
|
|
1658
|
+
`Nothing responding on ${local} \xB7 duto`,
|
|
746
1659
|
`<main>
|
|
747
1660
|
<div class="mark"><span class="dot"></span>duto \xB7 ${tunnel2}</div>
|
|
748
|
-
<h1>
|
|
749
|
-
<p>
|
|
750
|
-
<p>
|
|
751
|
-
<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>
|
|
752
1665
|
</main>${o.retry ? WAIT_SCRIPT : ""}`
|
|
753
1666
|
);
|
|
754
1667
|
}
|
|
@@ -761,56 +1674,36 @@ button{margin-top:18px;width:100%;padding:10px;border:0;border-radius:.3rem;back
|
|
|
761
1674
|
.error{margin:14px 0 0;color:#b42318;font-weight:600}
|
|
762
1675
|
@media (prefers-color-scheme:dark){.error{color:#f97066}}
|
|
763
1676
|
`;
|
|
764
|
-
function
|
|
1677
|
+
function safeNext(raw) {
|
|
765
1678
|
if (!raw || !raw.startsWith("/") || raw.startsWith("//") || raw.startsWith("/\\")) return "/";
|
|
766
1679
|
return /^[\x21-\x7e]+$/.test(raw) ? raw : "/";
|
|
767
1680
|
}
|
|
768
1681
|
function loginPage(o) {
|
|
769
1682
|
return pageShell(
|
|
770
|
-
"
|
|
1683
|
+
"Sign in \xB7 duto",
|
|
771
1684
|
`<main>
|
|
772
1685
|
<div class="mark"><span class="dot"></span>duto \xB7 ${esc(o.tunnel)}</div>
|
|
773
|
-
<h1>
|
|
774
|
-
<p>
|
|
775
|
-
<form method="post" action="/__duto/
|
|
776
|
-
<input type="hidden" name="
|
|
777
|
-
<label for="
|
|
778
|
-
<input id="
|
|
779
|
-
<label for="
|
|
780
|
-
<input id="
|
|
781
|
-
${o.error ? `<p class="error" role="alert">
|
|
782
|
-
` : ""}<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>
|
|
783
1696
|
</form>
|
|
784
|
-
<div class="retry">
|
|
1697
|
+
<div class="retry">You stay signed in until the tunnel closes.</div>
|
|
785
1698
|
</main>`,
|
|
786
1699
|
CSS
|
|
787
1700
|
);
|
|
788
1701
|
}
|
|
789
1702
|
|
|
790
|
-
// src/session/replay.ts
|
|
791
|
-
function replayBlock(r) {
|
|
792
|
-
if (!r) return "Essa requisi\xE7\xE3o j\xE1 saiu da lista.";
|
|
793
|
-
if (r.websocket) return "WebSocket n\xE3o d\xE1 para reenviar.";
|
|
794
|
-
if (r.noReplay) return r.noReplay;
|
|
795
|
-
if (r.access?.kind === "denied") return "Barrada pela senha do t\xFAnel; n\xE3o d\xE1 para reenviar.";
|
|
796
|
-
if (!r.reqComplete) return "A requisi\xE7\xE3o ainda n\xE3o chegou inteira.";
|
|
797
|
-
if (r.reqTruncated) return "Corpo acima de 64 KB; n\xE3o d\xE1 para reenviar.";
|
|
798
|
-
if (r.reqBodyBytes > 0 && r.reqRaw === null) return "Corpo bin\xE1rio n\xE3o \xE9 guardado; n\xE3o d\xE1 para reenviar.";
|
|
799
|
-
return null;
|
|
800
|
-
}
|
|
801
|
-
var DROP = /* @__PURE__ */ new Set(["connection", "keep-alive", "transfer-encoding", "expect", "content-length", "upgrade", "te", "trailer", "proxy-connection"]);
|
|
802
|
-
function replayHeaders(r) {
|
|
803
|
-
const out = {};
|
|
804
|
-
for (const [k, v] of Object.entries(r.reqHeaders)) if (!DROP.has(k.toLowerCase())) out[k] = v;
|
|
805
|
-
const hadLength = Object.keys(r.reqHeaders).some((k) => k.toLowerCase() === "content-length");
|
|
806
|
-
if (r.reqBodyBytes > 0 || hadLength) out["content-length"] = String(r.reqRaw?.length ?? 0);
|
|
807
|
-
return out;
|
|
808
|
-
}
|
|
809
|
-
|
|
810
1703
|
// src/session/proxy.ts
|
|
811
|
-
var DENIED_NO_REPLAY = "
|
|
812
|
-
var PREFLIGHT_NO_REPLAY = "
|
|
813
|
-
var
|
|
1704
|
+
var DENIED_NO_REPLAY = "Blocked by the tunnel password; cannot replay.";
|
|
1705
|
+
var PREFLIGHT_NO_REPLAY = "CORS preflight cannot be replayed.";
|
|
1706
|
+
var TEXT2 = /^(text\/|application\/(json|xml|javascript|x-www-form-urlencoded|graphql)|[^;]*\+(json|xml))/i;
|
|
814
1707
|
var MAX_BODY = 64 * 1024;
|
|
815
1708
|
var LOCAL_AGENT = new Agent({ keepAlive: false });
|
|
816
1709
|
function flat(h) {
|
|
@@ -827,7 +1720,6 @@ var Capture = class {
|
|
|
827
1720
|
max;
|
|
828
1721
|
chunks = [];
|
|
829
1722
|
size = 0;
|
|
830
|
-
/** Bytes vistos, capturados ou não. */
|
|
831
1723
|
total = 0;
|
|
832
1724
|
truncated = false;
|
|
833
1725
|
push(c) {
|
|
@@ -850,6 +1742,7 @@ var Capture = class {
|
|
|
850
1742
|
return this.enabled && this.size > 0 ? Buffer.concat(this.chunks).toString("utf8") : null;
|
|
851
1743
|
}
|
|
852
1744
|
};
|
|
1745
|
+
var HOST_CACHE_MS = 5e3;
|
|
853
1746
|
var hostCache = /* @__PURE__ */ new Map();
|
|
854
1747
|
function probe(host, port) {
|
|
855
1748
|
return new Promise((resolve) => {
|
|
@@ -865,7 +1758,7 @@ function probe(host, port) {
|
|
|
865
1758
|
}
|
|
866
1759
|
async function localHost(port) {
|
|
867
1760
|
const hit = hostCache.get(port);
|
|
868
|
-
if (hit && Date.now() - hit.at <
|
|
1761
|
+
if (hit && Date.now() - hit.at < HOST_CACHE_MS) return hit.host;
|
|
869
1762
|
const host = await probe("127.0.0.1", port) ? "127.0.0.1" : await probe("::1", port) ? "::1" : null;
|
|
870
1763
|
if (!host) return "127.0.0.1";
|
|
871
1764
|
hostCache.set(port, { host, at: Date.now() });
|
|
@@ -894,13 +1787,16 @@ function hostOf(req) {
|
|
|
894
1787
|
return (req.headers.host ?? "").replace(/:\d+$/, "").toLowerCase();
|
|
895
1788
|
}
|
|
896
1789
|
var DUTO_PREFIX = "/__duto/";
|
|
897
|
-
var WAIT_PATH = "/__duto/
|
|
1790
|
+
var WAIT_PATH = "/__duto/wait";
|
|
898
1791
|
var WAIT_MAX_MS = 10 * 6e4;
|
|
899
|
-
var
|
|
900
|
-
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";
|
|
901
1797
|
var QR_PREFIX = "/__duto/qr/";
|
|
902
1798
|
var MAX_FORM = 8 * 1024;
|
|
903
|
-
var DENIED_TEXT = "
|
|
1799
|
+
var DENIED_TEXT = "This tunnel is password protected. Send Authorization: Basic or X-Duto-Auth: user:password.";
|
|
904
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';
|
|
905
1801
|
function readForm(req, max) {
|
|
906
1802
|
return new Promise((resolve) => {
|
|
@@ -923,9 +1819,9 @@ function readForm(req, max) {
|
|
|
923
1819
|
}
|
|
924
1820
|
async function authRoute(req, res, route, auth) {
|
|
925
1821
|
const url = new URL(req.url ?? "/", "http://duto");
|
|
926
|
-
const page = (status,
|
|
1822
|
+
const page = (status, next, error) => {
|
|
927
1823
|
res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
928
|
-
res.end(loginPage({ tunnel: route.tunnel,
|
|
1824
|
+
res.end(loginPage({ tunnel: route.tunnel, next, error }));
|
|
929
1825
|
};
|
|
930
1826
|
const go = (location, cookie) => {
|
|
931
1827
|
res.writeHead(303, { location, "cache-control": "no-store", ...cookie && { "set-cookie": cookie } });
|
|
@@ -933,21 +1829,21 @@ async function authRoute(req, res, route, auth) {
|
|
|
933
1829
|
};
|
|
934
1830
|
const { pathname } = url;
|
|
935
1831
|
if (pathname === LOGIN_PATH && req.method === "GET") {
|
|
936
|
-
page(200,
|
|
1832
|
+
page(200, safeNext(url.searchParams.get("next")), false);
|
|
937
1833
|
return true;
|
|
938
1834
|
}
|
|
939
1835
|
if (pathname === LOGIN_PATH && req.method === "POST") {
|
|
940
1836
|
const raw = await readForm(req, MAX_FORM);
|
|
941
1837
|
if (raw === null) {
|
|
942
1838
|
res.writeHead(413, { "content-type": "text/plain; charset=utf-8", connection: "close" });
|
|
943
|
-
res.end("
|
|
1839
|
+
res.end("Form too large");
|
|
944
1840
|
return true;
|
|
945
1841
|
}
|
|
946
1842
|
const form = new URLSearchParams(raw);
|
|
947
|
-
const user = form.get("
|
|
948
|
-
const
|
|
949
|
-
if (auth.checkPassword(user, form.get("
|
|
950
|
-
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);
|
|
951
1847
|
return true;
|
|
952
1848
|
}
|
|
953
1849
|
if (pathname === LOGOUT_PATH && req.method === "GET") {
|
|
@@ -963,7 +1859,7 @@ async function authRoute(req, res, route, auth) {
|
|
|
963
1859
|
}
|
|
964
1860
|
function deny(req, res, html) {
|
|
965
1861
|
const status = html ? 303 : 401;
|
|
966
|
-
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) };
|
|
967
1863
|
const body = html || req.method === "HEAD" ? "" : DENIED_TEXT;
|
|
968
1864
|
res.writeHead(status, out);
|
|
969
1865
|
res.end(body);
|
|
@@ -1005,7 +1901,7 @@ function waitForService(res, port, checkMs, pingMs) {
|
|
|
1005
1901
|
async function startProxy(o) {
|
|
1006
1902
|
const max = o.maxBody ?? MAX_BODY;
|
|
1007
1903
|
let nextId = 1;
|
|
1008
|
-
const routeWaitMs = o.routeWaitMs ??
|
|
1904
|
+
const routeWaitMs = o.routeWaitMs ?? DEFAULT_ROUTE_WAIT_MS;
|
|
1009
1905
|
async function routeFor(req) {
|
|
1010
1906
|
const host = hostOf(req);
|
|
1011
1907
|
for (let waited = 0; ; waited += 50) {
|
|
@@ -1043,7 +1939,7 @@ async function startProxy(o) {
|
|
|
1043
1939
|
return { id, started };
|
|
1044
1940
|
}
|
|
1045
1941
|
function trackResponse(id, started, up, reqCap) {
|
|
1046
|
-
const resCap = new Capture(
|
|
1942
|
+
const resCap = new Capture(TEXT2.test(up.headers["content-type"] ?? ""), max);
|
|
1047
1943
|
let bytes = 0;
|
|
1048
1944
|
up.on("data", (c) => {
|
|
1049
1945
|
bytes += c.length;
|
|
@@ -1066,9 +1962,9 @@ async function startProxy(o) {
|
|
|
1066
1962
|
function replay(id) {
|
|
1067
1963
|
const r = o.store.get(id);
|
|
1068
1964
|
const blocked = replayBlock(r);
|
|
1069
|
-
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." };
|
|
1070
1966
|
const route = [...o.routes.values()].find((x) => x.tunnel === r.tunnel);
|
|
1071
|
-
if (!route) return { ok: false, reason:
|
|
1967
|
+
if (!route) return { ok: false, reason: tunnelClosedReason(r.tunnel) };
|
|
1072
1968
|
const newId = nextId++;
|
|
1073
1969
|
const started = Date.now();
|
|
1074
1970
|
const headers = replayHeaders(r);
|
|
@@ -1102,22 +1998,22 @@ async function startProxy(o) {
|
|
|
1102
1998
|
})();
|
|
1103
1999
|
return { ok: true, id: newId };
|
|
1104
2000
|
}
|
|
1105
|
-
const server =
|
|
2001
|
+
const server = createServer2(async (req, res) => {
|
|
1106
2002
|
req.pause();
|
|
1107
2003
|
const route = await routeFor(req);
|
|
1108
2004
|
if (!route) {
|
|
1109
2005
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
1110
|
-
res.end("
|
|
2006
|
+
res.end("Tunnel not found in this session");
|
|
1111
2007
|
return;
|
|
1112
2008
|
}
|
|
1113
2009
|
if ((req.url ?? "").startsWith(DUTO_PREFIX)) {
|
|
1114
2010
|
if (req.url === WAIT_PATH && req.method === "GET") {
|
|
1115
2011
|
if (route.auth?.access(req.headers).kind === "denied") return void deny(req, res, false);
|
|
1116
|
-
return waitForService(res, route.port, o.waitCheckMs ??
|
|
2012
|
+
return waitForService(res, route.port, o.waitCheckMs ?? DEFAULT_WAIT_CHECK_MS, o.waitPingMs ?? SSE_KEEPALIVE_MS);
|
|
1117
2013
|
}
|
|
1118
2014
|
if (route.auth && await authRoute(req, res, route, route.auth)) return;
|
|
1119
2015
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
1120
|
-
res.end("
|
|
2016
|
+
res.end("Reserved Duto route");
|
|
1121
2017
|
return;
|
|
1122
2018
|
}
|
|
1123
2019
|
const preflight = !!route.auth && isPreflight(req.method, req.headers);
|
|
@@ -1131,7 +2027,7 @@ async function startProxy(o) {
|
|
|
1131
2027
|
return;
|
|
1132
2028
|
}
|
|
1133
2029
|
const { id, started } = record(req, route, headers, false, access, preflight ? PREFLIGHT_NO_REPLAY : void 0);
|
|
1134
|
-
const reqCap = new Capture(
|
|
2030
|
+
const reqCap = new Capture(TEXT2.test(req.headers["content-type"] ?? ""), max);
|
|
1135
2031
|
req.pause();
|
|
1136
2032
|
const host = await localHost(route.port);
|
|
1137
2033
|
const upstream = request(
|
|
@@ -1160,7 +2056,7 @@ async function startProxy(o) {
|
|
|
1160
2056
|
req.resume();
|
|
1161
2057
|
if (res.destroyed && res.writableFinished === false && !res.headersSent) return;
|
|
1162
2058
|
const html = wantsHtml(req.headers.accept);
|
|
1163
|
-
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.`;
|
|
1164
2060
|
if (!res.headersSent) {
|
|
1165
2061
|
res.writeHead(503, { "content-type": html ? "text/html; charset=utf-8" : "text/plain; charset=utf-8", "cache-control": "no-store", "retry-after": "3" });
|
|
1166
2062
|
res.end(req.method === "HEAD" ? void 0 : msg);
|
|
@@ -1201,8 +2097,8 @@ async function startProxy(o) {
|
|
|
1201
2097
|
target.write(`${raw}\r
|
|
1202
2098
|
`);
|
|
1203
2099
|
if (head.length) target.write(head);
|
|
1204
|
-
target.once("data", (
|
|
1205
|
-
const status = Number(/^HTTP\/\d\.\d (\d{3})/.exec(
|
|
2100
|
+
target.once("data", (first3) => {
|
|
2101
|
+
const status = Number(/^HTTP\/\d\.\d (\d{3})/.exec(first3.toString("latin1"))?.[1] ?? 0) || null;
|
|
1206
2102
|
o.store.update(id, { status, durationMs: Date.now() - started });
|
|
1207
2103
|
});
|
|
1208
2104
|
target.pipe(client);
|
|
@@ -1324,7 +2220,6 @@ var RecordStore = class {
|
|
|
1324
2220
|
get(id) {
|
|
1325
2221
|
return this.items.find((x) => x.id === id);
|
|
1326
2222
|
}
|
|
1327
|
-
/** Mais recentes primeiro. Mesma referência até a próxima mudança (serve de snapshot para a tela). */
|
|
1328
2223
|
list() {
|
|
1329
2224
|
this.snapshot ??= [...this.items].reverse();
|
|
1330
2225
|
return this.snapshot;
|
|
@@ -1344,8 +2239,8 @@ var READY = "Registered tunnel connection";
|
|
|
1344
2239
|
var CLOSE_TIMEOUT_MS = 3e3;
|
|
1345
2240
|
var HEARTBEAT_MS = 3e4;
|
|
1346
2241
|
var RESTART_CF_MS = 2e3;
|
|
1347
|
-
var ALREADY_OPEN = "
|
|
1348
|
-
var
|
|
2242
|
+
var ALREADY_OPEN = "Already open in another session on this machine";
|
|
2243
|
+
var STALE_AFTER_MISSED_HEARTBEATS_MS = 75e3;
|
|
1349
2244
|
function message(err) {
|
|
1350
2245
|
return err instanceof Error ? err.message : String(err);
|
|
1351
2246
|
}
|
|
@@ -1353,7 +2248,7 @@ function withTimeout(p, ms) {
|
|
|
1353
2248
|
return Promise.race([p, new Promise((r) => setTimeout(() => r(void 0), ms).unref())]);
|
|
1354
2249
|
}
|
|
1355
2250
|
function keyOf(spec) {
|
|
1356
|
-
return spec.name ?? "(
|
|
2251
|
+
return spec.name ?? "(default)";
|
|
1357
2252
|
}
|
|
1358
2253
|
var Session = class {
|
|
1359
2254
|
constructor(o) {
|
|
@@ -1365,8 +2260,7 @@ var Session = class {
|
|
|
1365
2260
|
tunnels = [];
|
|
1366
2261
|
routes = /* @__PURE__ */ new Map();
|
|
1367
2262
|
specs = /* @__PURE__ */ new Map();
|
|
1368
|
-
|
|
1369
|
-
auths = /* @__PURE__ */ new Map();
|
|
2263
|
+
persistentAuths = /* @__PURE__ */ new Map();
|
|
1370
2264
|
proxy = null;
|
|
1371
2265
|
child = null;
|
|
1372
2266
|
token = null;
|
|
@@ -1377,7 +2271,6 @@ var Session = class {
|
|
|
1377
2271
|
connection = "connecting";
|
|
1378
2272
|
listeners = /* @__PURE__ */ new Set();
|
|
1379
2273
|
snapshot = null;
|
|
1380
|
-
/** Mesma referência até a próxima mudança (serve de snapshot para a tela). */
|
|
1381
2274
|
getState() {
|
|
1382
2275
|
this.snapshot ??= { account: this.account, source: this.o.source, connection: this.connection, tunnels: this.tunnels.map((t) => ({ ...t })) };
|
|
1383
2276
|
return this.snapshot;
|
|
@@ -1386,23 +2279,18 @@ var Session = class {
|
|
|
1386
2279
|
this.listeners.add(fn);
|
|
1387
2280
|
return () => this.listeners.delete(fn);
|
|
1388
2281
|
}
|
|
1389
|
-
/** Abre os túneis em sequência; falhas ficam no estado e não interrompem os outros. */
|
|
1390
2282
|
async start(specs) {
|
|
1391
2283
|
if (this.o.runDir) reapOrphans(this.o.runDir);
|
|
1392
2284
|
this.proxy = await startProxy({ routes: this.routes, store: this.records });
|
|
1393
2285
|
this.tunnels = specs.map((s) => this.blank(s, false));
|
|
1394
2286
|
this.emit();
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
this.account = me.username ?? me.email;
|
|
1398
|
-
} catch {
|
|
1399
|
-
}
|
|
2287
|
+
const me = await this.o.api.me().catch(() => null);
|
|
2288
|
+
if (me) this.account = me.username ?? me.email;
|
|
1400
2289
|
const existing = await this.o.api.endpoints().catch(() => []);
|
|
1401
2290
|
for (const t of this.tunnels) await this.open(t, existing);
|
|
1402
2291
|
const every = this.o.heartbeatMs ?? HEARTBEAT_MS;
|
|
1403
2292
|
this.timer = setInterval(() => void this.beat(), every);
|
|
1404
2293
|
}
|
|
1405
|
-
/** Túnel avulso, só nesta sessão. */
|
|
1406
2294
|
async add(spec) {
|
|
1407
2295
|
if (this.tunnels.some((t2) => t2.key === keyOf(spec))) return;
|
|
1408
2296
|
const t = this.blank(spec, true);
|
|
@@ -1411,29 +2299,29 @@ var Session = class {
|
|
|
1411
2299
|
const existing = await this.o.api.endpoints().catch(() => []);
|
|
1412
2300
|
await this.open(t, existing);
|
|
1413
2301
|
}
|
|
1414
|
-
/** Link para o QR: com senha, um token de uso único; sem senha, só a URL. */
|
|
1415
2302
|
qrLink(key) {
|
|
1416
2303
|
const t = this.tunnels.find((x) => x.key === key);
|
|
1417
2304
|
if (!t?.url) return null;
|
|
1418
|
-
const auth = this.
|
|
2305
|
+
const auth = this.persistentAuths.get(key);
|
|
1419
2306
|
if (!auth) return { url: t.url, expiresAt: null };
|
|
1420
2307
|
const { token, expiresAt } = auth.issueQrToken();
|
|
1421
2308
|
return { url: `${t.url}/__duto/qr/${token}`, expiresAt };
|
|
1422
2309
|
}
|
|
1423
|
-
|
|
2310
|
+
authUser(key) {
|
|
2311
|
+
return this.persistentAuths.get(key)?.qrUser ?? null;
|
|
2312
|
+
}
|
|
1424
2313
|
async authFor(key) {
|
|
1425
|
-
const existing = this.
|
|
2314
|
+
const existing = this.persistentAuths.get(key);
|
|
1426
2315
|
if (existing) return existing;
|
|
1427
2316
|
const spec = this.specs.get(key);
|
|
1428
2317
|
if (!spec?.auth?.length) return void 0;
|
|
1429
2318
|
const allow = spec.allow?.length ? await (this.o.resolveAllow ?? resolveAllow)(spec.allow) : void 0;
|
|
1430
2319
|
const auth = new TunnelAuth({ tunnel: key, credentials: spec.auth, allow });
|
|
1431
|
-
this.
|
|
2320
|
+
this.persistentAuths.set(key, auth);
|
|
1432
2321
|
return auth;
|
|
1433
2322
|
}
|
|
1434
|
-
/** Reenvia uma requisição da lista ao serviço local. */
|
|
1435
2323
|
replay(id) {
|
|
1436
|
-
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." };
|
|
1437
2325
|
}
|
|
1438
2326
|
async stop() {
|
|
1439
2327
|
if (this.stopped) return;
|
|
@@ -1452,10 +2340,10 @@ var Session = class {
|
|
|
1452
2340
|
}
|
|
1453
2341
|
async open(t, existing, retrying = false) {
|
|
1454
2342
|
if (this.stopped) return;
|
|
1455
|
-
const
|
|
1456
|
-
(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
|
|
1457
2345
|
);
|
|
1458
|
-
if (
|
|
2346
|
+
if (takenByLiveSession) return this.fail(t, ALREADY_OPEN);
|
|
1459
2347
|
try {
|
|
1460
2348
|
const res = await this.o.api.openTunnel({
|
|
1461
2349
|
deviceId: this.o.deviceId,
|
|
@@ -1631,7 +2519,7 @@ function runPlain(session, out) {
|
|
|
1631
2519
|
}
|
|
1632
2520
|
|
|
1633
2521
|
// src/index.ts
|
|
1634
|
-
var LOGIN_HINT = "
|
|
2522
|
+
var LOGIN_HINT = "Log in first: duto login <token> (get your token at https://duto.dev)";
|
|
1635
2523
|
function fail2(message2) {
|
|
1636
2524
|
console.error(fail(message2));
|
|
1637
2525
|
process.exit(1);
|
|
@@ -1639,19 +2527,19 @@ function fail2(message2) {
|
|
|
1639
2527
|
function parsePort(raw) {
|
|
1640
2528
|
const s = String(raw ?? "");
|
|
1641
2529
|
const n = Number(s);
|
|
1642
|
-
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)`);
|
|
1643
2531
|
return n;
|
|
1644
2532
|
}
|
|
1645
2533
|
function parseName(raw) {
|
|
1646
2534
|
if (raw === void 0 || raw === true || raw === "") return void 0;
|
|
1647
2535
|
const name = normalizeName(String(raw));
|
|
1648
2536
|
const v = validateName(name);
|
|
1649
|
-
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)");
|
|
1650
2538
|
return name;
|
|
1651
2539
|
}
|
|
1652
2540
|
function parseHostFlag(raw) {
|
|
1653
2541
|
if (raw === void 0 || raw === true || raw === "") return void 0;
|
|
1654
|
-
return parseHost(String(raw)) ?? fail2("
|
|
2542
|
+
return parseHost(String(raw)) ?? fail2("Invalid host: use the domain only, like myapp.test");
|
|
1655
2543
|
}
|
|
1656
2544
|
function listFlag(raw) {
|
|
1657
2545
|
if (raw === void 0) return [];
|
|
@@ -1661,14 +2549,14 @@ function parseAuthFlags(raw) {
|
|
|
1661
2549
|
const out = [];
|
|
1662
2550
|
for (const entry of listFlag(raw)) {
|
|
1663
2551
|
const r = parseCredential(entry);
|
|
1664
|
-
if (!r.ok) fail2(r.reason === "password" ? "
|
|
1665
|
-
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}"`);
|
|
1666
2554
|
out.push(r.cred);
|
|
1667
2555
|
}
|
|
1668
2556
|
return out;
|
|
1669
2557
|
}
|
|
1670
2558
|
function parseAllowFlags(raw) {
|
|
1671
|
-
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`));
|
|
1672
2560
|
}
|
|
1673
2561
|
function wantsUi(flags) {
|
|
1674
2562
|
if (flags.ui === false) return false;
|
|
@@ -1687,11 +2575,24 @@ async function runSession(specs, source, flags) {
|
|
|
1687
2575
|
fail2(err instanceof Error ? err.message : String(err));
|
|
1688
2576
|
}
|
|
1689
2577
|
const session = new Session({ api, deviceId: config.deviceId, deviceName: hostname(), cloudflaredPath, source, runDir: join6(dutoHome(), "run") });
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
2578
|
+
let inspector = null;
|
|
2579
|
+
let inspectorError;
|
|
2580
|
+
if (flags.inspect !== false) {
|
|
2581
|
+
try {
|
|
2582
|
+
inspector = await startInspector({ session, page: inspectorPage });
|
|
2583
|
+
} catch (err) {
|
|
2584
|
+
inspectorError = err instanceof Error ? err.message : String(err);
|
|
2585
|
+
}
|
|
1693
2586
|
}
|
|
1694
|
-
|
|
2587
|
+
if (wantsUi(flags)) {
|
|
2588
|
+
const { runFullscreen } = await import("./fullscreen-IWZCH3IP.js");
|
|
2589
|
+
const code = await runFullscreen(session, specs, "1.0.0", inspector, inspectorError);
|
|
2590
|
+
await inspector?.close().catch(() => void 0);
|
|
2591
|
+
process.exit(code);
|
|
2592
|
+
}
|
|
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}`));
|
|
1695
2596
|
const stopLog = runPlain(session, (l) => console.log(l));
|
|
1696
2597
|
const stopped = new Promise((resolve) => {
|
|
1697
2598
|
const onSignal = () => {
|
|
@@ -1708,13 +2609,14 @@ async function runSession(specs, source, flags) {
|
|
|
1708
2609
|
await stopped;
|
|
1709
2610
|
stopLog();
|
|
1710
2611
|
await session.stop();
|
|
2612
|
+
await inspector?.close().catch(() => void 0);
|
|
1711
2613
|
process.exit(0);
|
|
1712
2614
|
}
|
|
1713
2615
|
async function tunnel(rawPort, flags) {
|
|
1714
2616
|
if (rawPort === void 0) {
|
|
1715
|
-
if (flags.host !== void 0) fail2("Use --host
|
|
1716
|
-
if (flags.auth !== void 0) fail2("Use --auth
|
|
1717
|
-
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");
|
|
1718
2620
|
let found;
|
|
1719
2621
|
try {
|
|
1720
2622
|
found = findConfig(process.cwd());
|
|
@@ -1724,7 +2626,7 @@ async function tunnel(rawPort, flags) {
|
|
|
1724
2626
|
if (!found) {
|
|
1725
2627
|
cli.outputHelp();
|
|
1726
2628
|
console.log(`
|
|
1727
|
-
${paint("signal", "
|
|
2629
|
+
${paint("signal", "Tip:")} create a duto.yml with your project's tunnels and just run ${paint("strong", "duto")}:
|
|
1728
2630
|
|
|
1729
2631
|
tunnels:
|
|
1730
2632
|
web: 3000
|
|
@@ -1738,30 +2640,30 @@ ${paint("signal", "Dica:")} crie um duto.yml com os t\xFAneis do projeto e rode
|
|
|
1738
2640
|
const name = parseName(flags.name) ?? null;
|
|
1739
2641
|
const host = parseHostFlag(flags.host);
|
|
1740
2642
|
const keepRedirects = flags.rewriteRedirects === false;
|
|
1741
|
-
if (keepRedirects && !host) fail2("--no-rewrite-redirects
|
|
2643
|
+
if (keepRedirects && !host) fail2("--no-rewrite-redirects only works with --host");
|
|
1742
2644
|
const auth = parseAuthFlags(flags.auth);
|
|
1743
|
-
const allow = parseAllowFlags(flags.
|
|
1744
|
-
if (allow.length && !auth.length) fail2("--
|
|
1745
|
-
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);
|
|
1746
2648
|
}
|
|
1747
2649
|
var cli = cac("duto");
|
|
1748
|
-
cli.command("[port]", "
|
|
1749
|
-
cli.command("http <port>", "
|
|
1750
|
-
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) => {
|
|
1751
2653
|
try {
|
|
1752
2654
|
const me = await createApiClient({ baseUrl: apiUrl(), token: String(token) }).me();
|
|
1753
2655
|
saveConfig({ ...loadConfig(), authtoken: String(token) });
|
|
1754
|
-
console.log(ok(`
|
|
2656
|
+
console.log(ok(`Logged in as ${paint("strong", me.username ?? me.email)}`));
|
|
1755
2657
|
} catch (err) {
|
|
1756
2658
|
fail2(err instanceof Error ? err.message : String(err));
|
|
1757
2659
|
}
|
|
1758
2660
|
});
|
|
1759
|
-
cli.command("logout", "Remove
|
|
2661
|
+
cli.command("logout", "Remove the authtoken from this machine").action(() => {
|
|
1760
2662
|
const { authtoken: _removed, ...rest } = loadConfig();
|
|
1761
2663
|
saveConfig(rest);
|
|
1762
|
-
console.log(ok("Authtoken
|
|
2664
|
+
console.log(ok("Authtoken removed"));
|
|
1763
2665
|
});
|
|
1764
|
-
cli.command("status", "
|
|
2666
|
+
cli.command("status", "Show your account and live tunnels").action(async () => {
|
|
1765
2667
|
const config = loadConfig();
|
|
1766
2668
|
if (!config.authtoken) fail2(LOGIN_HINT);
|
|
1767
2669
|
try {
|
|
@@ -1769,23 +2671,17 @@ cli.command("status", "Mostra a conta e os t\xFAneis no ar").action(async () =>
|
|
|
1769
2671
|
const [me, endpoints] = await Promise.all([api.me(), api.endpoints()]);
|
|
1770
2672
|
console.log(`${mark()} ${paint("muted", `${me.username} \xB7 ${me.email}`)}`);
|
|
1771
2673
|
const live = endpoints.filter((e) => e.connected);
|
|
1772
|
-
if (!live.length) console.log(paint("muted", "
|
|
2674
|
+
if (!live.length) console.log(paint("muted", "No live tunnels."));
|
|
1773
2675
|
for (const e of live)
|
|
1774
2676
|
console.log(
|
|
1775
|
-
`${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"}`}`)}`
|
|
1776
2678
|
);
|
|
1777
2679
|
} catch (err) {
|
|
1778
2680
|
fail2(err instanceof DutoApiError ? err.message : String(err));
|
|
1779
2681
|
}
|
|
1780
2682
|
});
|
|
1781
|
-
cli.help(
|
|
1782
|
-
|
|
1783
|
-
...s,
|
|
1784
|
-
title: s.title === "Usage" ? "Uso" : s.title === "Commands" ? "Comandos" : s.title === "Options" ? "Op\xE7\xF5es" : s.title,
|
|
1785
|
-
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")
|
|
1786
|
-
}))
|
|
1787
|
-
);
|
|
1788
|
-
cli.version("0.6.0");
|
|
2683
|
+
cli.help();
|
|
2684
|
+
cli.version("1.0.0");
|
|
1789
2685
|
try {
|
|
1790
2686
|
cli.parse(process.argv, { run: false });
|
|
1791
2687
|
await cli.runMatchedCommand();
|