@dutodev/cli 0.4.5 → 0.5.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/dist/index.js CHANGED
@@ -33,7 +33,7 @@ var fail = (msg) => `${paint("err", "\u2716", process.stderr)} ${msg}`;
33
33
 
34
34
  // src/index.ts
35
35
  import { hostname } from "os";
36
- import { relative } from "path";
36
+ import { join as join6, relative } from "path";
37
37
  import cac from "cac";
38
38
 
39
39
  // src/api.ts
@@ -224,10 +224,142 @@ async function ensureCloudflaredWithProgress(write, opts = {}) {
224
224
  }
225
225
  }
226
226
 
227
- // src/session/config.ts
228
- import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
227
+ // src/session/allowlist.ts
228
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
229
+ import { BlockList, isIP } from "net";
229
230
  import { dirname, join as join3 } from "path";
230
- import { isMap, isScalar, LineCounter, parseDocument } from "yaml";
231
+
232
+ // src/session/allowlist-snapshot.ts
233
+ var ALLOW_SNAPSHOT = {
234
+ stripe: [
235
+ "3.18.12.63/32",
236
+ "3.69.109.8/32",
237
+ "3.120.168.93/32",
238
+ "3.130.192.231/32",
239
+ "13.235.14.237/32",
240
+ "13.235.122.149/32",
241
+ "18.211.135.69/32",
242
+ "35.154.171.200/32",
243
+ "35.157.207.129/32",
244
+ "52.15.183.38/32",
245
+ "54.88.130.119/32",
246
+ "54.88.130.237/32",
247
+ "54.187.174.169/32",
248
+ "54.187.205.235/32",
249
+ "54.187.216.72/32"
250
+ ],
251
+ github: [
252
+ "9.234.106.48/28",
253
+ "9.234.98.160/28",
254
+ "9.234.98.176/28",
255
+ "192.30.252.0/22",
256
+ "185.199.108.0/22",
257
+ "140.82.112.0/20",
258
+ "143.55.64.0/20",
259
+ "2a0a:a440::/29",
260
+ "2606:50c0::/32"
261
+ ]
262
+ };
263
+
264
+ // src/session/allowlist.ts
265
+ var ALLOW_NAMES = ["stripe", "github"];
266
+ var CACHE_MS = 24 * 36e5;
267
+ var FETCH_TIMEOUT_MS = 3e3;
268
+ var SOURCES = {
269
+ stripe: { url: "https://stripe.com/files/ips/ips_webhooks.json", pick: (j) => j?.WEBHOOKS },
270
+ github: { url: "https://api.github.com/meta", pick: (j) => j?.hooks }
271
+ };
272
+ function splitCidr(raw) {
273
+ const [ip, p, ...resto] = raw.split("/");
274
+ if (!ip || resto.length) return null;
275
+ const v = isIP(ip);
276
+ if (!v) return null;
277
+ const max = v === 4 ? 32 : 128;
278
+ if (p === void 0) return { ip, family: v, prefix: max };
279
+ if (!/^\d{1,3}$/.test(p)) return null;
280
+ const prefix = Number(p);
281
+ return prefix <= max ? { ip, family: v, prefix } : null;
282
+ }
283
+ function parseAllowEntry(raw) {
284
+ if (ALLOW_NAMES.includes(raw)) return { kind: "name", name: raw };
285
+ const c = splitCidr(raw);
286
+ if (!c) return null;
287
+ return { kind: "cidr", cidr: raw.includes("/") ? raw : `${raw}/${c.prefix}` };
288
+ }
289
+ function blockListOf(cidrs) {
290
+ const bl = new BlockList();
291
+ for (const cidr of cidrs) {
292
+ const c = splitCidr(cidr);
293
+ if (c) bl.addSubnet(c.ip, c.prefix, c.family === 4 ? "ipv4" : "ipv6");
294
+ }
295
+ return bl;
296
+ }
297
+ function readCache(file) {
298
+ try {
299
+ const c = JSON.parse(readFileSync2(file, "utf8"));
300
+ if (typeof c?.fetchedAt === "number" && c.lists && typeof c.lists === "object") return c;
301
+ } catch {
302
+ }
303
+ return null;
304
+ }
305
+ async function fetchRanges(name, f) {
306
+ const src = SOURCES[name];
307
+ const res = await f(src.url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { accept: "application/json" } });
308
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
309
+ const list = src.pick(await res.json());
310
+ if (!Array.isArray(list)) throw new Error("formato inesperado");
311
+ const out = list.filter((x) => typeof x === "string" && splitCidr(x) !== null);
312
+ if (!out.length) throw new Error("lista vazia");
313
+ return out.map((x) => x.includes("/") ? x : `${x}/${splitCidr(x).prefix}`);
314
+ }
315
+ async function resolveAllow(entries, o = {}) {
316
+ const cacheFile = o.cacheFile ?? join3(dutoHome(), "allowlist.json");
317
+ const f = o.fetch ?? fetch;
318
+ const now = (o.now ?? Date.now)();
319
+ const parsed = entries.map((raw) => ({ raw, e: parseAllowEntry(raw) }));
320
+ const names = [...new Set(parsed.flatMap((p) => p.e?.kind === "name" ? [p.e.name] : []))];
321
+ const cached = readCache(cacheFile);
322
+ const fresh = cached && now - cached.fetchedAt >= 0 && now - cached.fetchedAt < CACHE_MS ? cached : null;
323
+ const lists = {};
324
+ if (fresh && names.every((n) => Array.isArray(fresh.lists[n]) && fresh.lists[n].length)) {
325
+ for (const n of names) lists[n] = fresh.lists[n];
326
+ } else {
327
+ const net = {};
328
+ for (const name of names) {
329
+ try {
330
+ net[name] = lists[name] = await fetchRanges(name, f);
331
+ } catch {
332
+ lists[name] = ALLOW_SNAPSHOT[name];
333
+ }
334
+ }
335
+ if (names.length && Object.keys(net).length === names.length) {
336
+ try {
337
+ mkdirSync3(dirname(cacheFile), { recursive: true });
338
+ writeFileSync3(cacheFile, JSON.stringify({ fetchedAt: now, lists: net }));
339
+ } catch {
340
+ }
341
+ }
342
+ }
343
+ const blocks = parsed.flatMap(({ raw, e }) => {
344
+ if (!e) return [];
345
+ return [{ reason: e.kind === "name" ? e.name : raw, bl: blockListOf(e.kind === "name" ? lists[e.name] : [e.cidr]) }];
346
+ });
347
+ return {
348
+ match(ip) {
349
+ if (!ip) return null;
350
+ const plain = ip.replace(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i, "$1");
351
+ const v = isIP(plain);
352
+ if (!v) return null;
353
+ const fam = v === 4 ? "ipv4" : "ipv6";
354
+ return blocks.find((b) => b.bl.check(plain, fam))?.reason ?? null;
355
+ }
356
+ };
357
+ }
358
+
359
+ // src/session/config.ts
360
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
361
+ import { dirname as dirname2, join as join4 } from "path";
362
+ import { isMap, isScalar, isSeq, LineCounter, parseDocument } from "yaml";
231
363
  var ConfigError = class extends Error {
232
364
  constructor(message2, line, file = CONFIG_FILES[0]) {
233
365
  super(!file ? message2 : line ? `${file}, linha ${line}: ${message2}` : `${file}: ${message2}`);
@@ -240,16 +372,26 @@ var CONFIG_FILES = ["duto.yml", "duto.yaml"];
240
372
  var LABEL = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?";
241
373
  var HOST_RE = new RegExp(`^${LABEL}(?:\\.${LABEL})*(?::\\d{1,5})?$`);
242
374
  var HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
243
- var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects"];
375
+ var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "auth", "permitir"];
376
+ var ENV_REF_RE = /\$\{([A-Z_0-9]+)\}/g;
244
377
  function parseHost(raw) {
245
378
  const host = raw.trim().toLowerCase();
246
379
  return HOST_RE.test(host) ? host : null;
247
380
  }
381
+ function parseCredential(raw) {
382
+ const i = raw.indexOf(":");
383
+ if (i < 0) return { ok: false, reason: "format" };
384
+ const user = raw.slice(0, i);
385
+ const password = raw.slice(i + 1);
386
+ if (user.length < 1 || user.length > 128 || /\s/.test(user)) return { ok: false, reason: "user" };
387
+ if (password.length < 8 || password.length > 128) return { ok: false, reason: "password" };
388
+ return { ok: true, cred: { user, password } };
389
+ }
248
390
  function portOf(value) {
249
391
  const v = isMap(value) ? value.get("port") : isScalar(value) ? value.value : value;
250
392
  return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 65535 ? v : null;
251
393
  }
252
- function parseConfig(text, file = CONFIG_FILES[0]) {
394
+ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
253
395
  const fail3 = (message2, line) => new ConfigError(message2, line, file);
254
396
  const lc = new LineCounter();
255
397
  const doc = parseDocument(text, { lineCounter: lc, prettyErrors: false });
@@ -281,7 +423,7 @@ function parseConfig(text, file = CONFIG_FILES[0]) {
281
423
  const body = pair.value;
282
424
  for (const item of body.items) {
283
425
  const key = isScalar(item.key) ? String(item.key.value) : "";
284
- if (!TUNNEL_KEYS.includes(key)) throw fail3(`chave desconhecida em "${raw}": ${key} (use port, host, headers ou rewrite_redirects)`, line);
426
+ if (!TUNNEL_KEYS.includes(key)) throw fail3(`chave desconhecida em "${raw}": ${key} (use port, host, headers, rewrite_redirects, auth ou permitir)`, line);
285
427
  }
286
428
  const hostNode = body.get("host", true);
287
429
  if (hostNode !== void 0) {
@@ -315,6 +457,44 @@ function parseConfig(text, file = CONFIG_FILES[0]) {
315
457
  if (!spec.host) throw fail3(`rewrite_redirects de "${raw}" s\xF3 vale junto com host:`, line);
316
458
  if (value === false) spec.rewriteRedirects = false;
317
459
  }
460
+ const authNode = body.get("auth", true);
461
+ if (authNode !== void 0) {
462
+ if (!isSeq(authNode)) throw fail3(`auth de "${raw}" deve ser uma lista de usuario:senha`, line);
463
+ const auth = [];
464
+ for (const item of authNode.items) {
465
+ const iLine = lineAt(item.range?.[0]) ?? line;
466
+ const text2 = isScalar(item) && typeof item.value === "string" ? item.value : null;
467
+ if (text2 === null) throw fail3(`credencial inv\xE1lida em "${raw}": use usuario:senha`, iLine);
468
+ const expanded = text2.replace(ENV_REF_RE, (_m, name2) => {
469
+ const val = env[name2];
470
+ if (val === void 0) throw fail3(`vari\xE1vel de ambiente ${name2} n\xE3o definida (usada em auth de "${raw}")`, iLine);
471
+ return val;
472
+ });
473
+ const parsed = parseCredential(expanded);
474
+ if (!parsed.ok) {
475
+ if (parsed.reason === "format") throw fail3(`credencial inv\xE1lida em "${raw}": use usuario:senha`, iLine);
476
+ if (parsed.reason === "user") throw fail3(`usu\xE1rio inv\xE1lido em "${raw}": de 1 a 128 caracteres, sem espa\xE7o`, iLine);
477
+ throw fail3(`senha de "${expanded.slice(0, expanded.indexOf(":"))}" em "${raw}" precisa ter de 8 a 128 caracteres`, iLine);
478
+ }
479
+ if (auth.some((c) => c.user === parsed.cred.user)) throw fail3(`usu\xE1rio "${parsed.cred.user}" aparece duas vezes em "${raw}"`, iLine);
480
+ auth.push(parsed.cred);
481
+ }
482
+ if (!auth.length) throw fail3(`auth de "${raw}" n\xE3o pode ficar vazia`, line);
483
+ spec.auth = auth;
484
+ }
485
+ const allowNode = body.get("permitir", true);
486
+ if (allowNode !== void 0) {
487
+ if (!isSeq(allowNode)) throw fail3(`permitir de "${raw}" deve ser uma lista`, line);
488
+ if (!spec.auth) throw fail3(`permitir de "${raw}" s\xF3 vale junto com auth:`, line);
489
+ const allow = [];
490
+ for (const item of allowNode.items) {
491
+ const iLine = lineAt(item.range?.[0]) ?? line;
492
+ const value = isScalar(item) ? String(item.value) : "";
493
+ if (!parseAllowEntry(value)) throw fail3(`permitir de "${raw}": "${value}" n\xE3o \xE9 um nome conhecido (stripe, github) nem uma faixa de IP`, iLine);
494
+ allow.push(value);
495
+ }
496
+ if (allow.length) spec.allow = allow;
497
+ }
318
498
  }
319
499
  if (specs.some((s) => s.name === name)) throw fail3(`o nome "${raw}" aparece duas vezes`, line);
320
500
  specs.push(spec);
@@ -324,14 +504,14 @@ function parseConfig(text, file = CONFIG_FILES[0]) {
324
504
  function findConfig(cwd) {
325
505
  let dir = cwd;
326
506
  for (; ; ) {
327
- const present = CONFIG_FILES.filter((f) => existsSync3(join3(dir, f)));
507
+ const present = CONFIG_FILES.filter((f) => existsSync3(join4(dir, f)));
328
508
  if (present.length > 1) throw new ConfigError(`${CONFIG_FILES.join(" e ")} na mesma pasta. Apague um dos dois.`, void 0, "");
329
509
  if (present.length === 1) {
330
- const path = join3(dir, present[0]);
331
- return { path, tunnels: parseConfig(readFileSync2(path, "utf8"), present[0]) };
510
+ const path = join4(dir, present[0]);
511
+ return { path, tunnels: parseConfig(readFileSync3(path, "utf8"), present[0]) };
332
512
  }
333
- if (existsSync3(join3(dir, ".git"))) return null;
334
- const up = dirname(dir);
513
+ if (existsSync3(join4(dir, ".git"))) return null;
514
+ const up = dirname2(dir);
335
515
  if (up === dir) return null;
336
516
  dir = up;
337
517
  }
@@ -359,6 +539,148 @@ async function isPortListening(port, timeoutMs = 500) {
359
539
  return results.some(Boolean);
360
540
  }
361
541
 
542
+ // src/session/auth.ts
543
+ import { createHash as createHash2, createHmac, randomBytes, timingSafeEqual } from "crypto";
544
+ var TUNNEL_COOKIE = "__Host-duto_tunel";
545
+ var QR_TTL_MS = 5 * 6e4;
546
+ var sha = (s) => createHash2("sha256").update(s).digest();
547
+ var same = (a, b) => timingSafeEqual(sha(a), sha(b));
548
+ function splitPair(raw) {
549
+ const i = raw.indexOf(":");
550
+ return i < 0 ? null : { user: raw.slice(0, i), password: raw.slice(i + 1) };
551
+ }
552
+ function parseBasic(value) {
553
+ const m = /^basic +([A-Za-z0-9+/]+={0,2})$/i.exec(value?.trim() ?? "");
554
+ return m ? splitPair(Buffer.from(m[1], "base64").toString("utf8")) : null;
555
+ }
556
+ var first = (v) => Array.isArray(v) ? v[0] : v;
557
+ var bareHost = (s) => s.replace(/:\d+$/, "").toLowerCase();
558
+ function sameHostOrigin(h) {
559
+ if (first(h["sec-fetch-site"])?.toLowerCase() === "same-site") return false;
560
+ const origin = first(h.origin);
561
+ if (origin === void 0) return true;
562
+ let host;
563
+ try {
564
+ host = new URL(origin).host;
565
+ } catch {
566
+ return false;
567
+ }
568
+ return host !== "" && bareHost(host) === bareHost(h.host ?? "");
569
+ }
570
+ var TunnelAuth = class {
571
+ qrUser;
572
+ tunnel;
573
+ credentials;
574
+ allow;
575
+ now;
576
+ secret;
577
+ qr = null;
578
+ constructor(o) {
579
+ this.tunnel = o.tunnel;
580
+ this.credentials = o.credentials;
581
+ this.allow = o.allow;
582
+ this.now = o.now ?? Date.now;
583
+ this.secret = o.secret ?? randomBytes(32);
584
+ this.qrUser = o.credentials[0]?.user ?? "";
585
+ }
586
+ access(h) {
587
+ if (this.allow) {
588
+ const ip = first(h["cf-connecting-ip"]);
589
+ const reason = this.allow.match(ip);
590
+ if (reason) return { kind: "allow", reason };
591
+ }
592
+ const cookieUser = this.cookieUser(h.cookie);
593
+ if (cookieUser && sameHostOrigin(h)) return { kind: "user", user: cookieUser };
594
+ const duto = first(h["x-duto-auth"]);
595
+ const cand = duto !== void 0 ? splitPair(duto) : parseBasic(first(h.authorization));
596
+ if (cand && this.checkPassword(cand.user, cand.password)) return { kind: "user", user: cand.user };
597
+ return { kind: "denied" };
598
+ }
599
+ checkPassword(user, password) {
600
+ let ok2 = false;
601
+ for (const c of this.credentials) {
602
+ const userOk = same(c.user, user);
603
+ const passOk = same(c.password, password);
604
+ if (userOk && passOk) ok2 = true;
605
+ }
606
+ return ok2;
607
+ }
608
+ loginCookie(user) {
609
+ return `${TUNNEL_COOKIE}=${this.cookieValue(user)}; Secure; HttpOnly; SameSite=Lax; Path=/`;
610
+ }
611
+ logoutCookie() {
612
+ return `${TUNNEL_COOKIE}=; Max-Age=0; Secure; HttpOnly; SameSite=Lax; Path=/`;
613
+ }
614
+ issueQrToken() {
615
+ this.qr = { token: randomBytes(24).toString("base64url"), expiresAt: this.now() + QR_TTL_MS };
616
+ return { ...this.qr };
617
+ }
618
+ consumeQrToken(token) {
619
+ const qr = this.qr;
620
+ if (!qr || !same(qr.token, token)) return false;
621
+ this.qr = null;
622
+ return this.now() <= qr.expiresAt;
623
+ }
624
+ strip(h) {
625
+ const out = { ...h };
626
+ delete out["x-duto-auth"];
627
+ const basic = parseBasic(first(h.authorization));
628
+ if (basic && this.checkPassword(basic.user, basic.password)) delete out.authorization;
629
+ if (h.cookie !== void 0) {
630
+ const parts = (Array.isArray(h.cookie) ? h.cookie.join("; ") : h.cookie).split(";").map((p) => p.trim()).filter(Boolean);
631
+ const kept = parts.filter((p) => !p.startsWith(`${TUNNEL_COOKIE}=`));
632
+ if (kept.length === parts.length) return out;
633
+ if (kept.length) out.cookie = kept.join("; ");
634
+ else delete out.cookie;
635
+ }
636
+ return out;
637
+ }
638
+ // Valor: usuário em base64url + "." + HMAC(túnel, usuário).
639
+ cookieValue(user) {
640
+ return `${Buffer.from(user).toString("base64url")}.${this.sign(user)}`;
641
+ }
642
+ sign(user) {
643
+ return createHmac("sha256", this.secret).update(`${this.tunnel}\0${user}`).digest("base64url");
644
+ }
645
+ cookieUser(header) {
646
+ if (header === void 0) return null;
647
+ const list = (Array.isArray(header) ? header.join("; ") : header).split(";");
648
+ for (const p of list) {
649
+ const item = p.trim();
650
+ if (!item.startsWith(`${TUNNEL_COOKIE}=`)) continue;
651
+ const [b64, sig, ...resto] = item.slice(TUNNEL_COOKIE.length + 1).split(".");
652
+ if (!b64 || !sig || resto.length) continue;
653
+ const user = Buffer.from(b64, "base64url").toString("utf8");
654
+ if (same(sig, this.sign(user)) && this.credentials.some((c) => c.user === user)) return user;
655
+ }
656
+ return null;
657
+ }
658
+ };
659
+ function isPreflight(method, h) {
660
+ if (method?.toUpperCase() !== "OPTIONS") return false;
661
+ if (h.origin === void 0 || h["access-control-request-method"] === void 0) return false;
662
+ if (h["transfer-encoding"] !== void 0) return false;
663
+ const len = h["content-length"];
664
+ return len === void 0 || Number(len) === 0;
665
+ }
666
+ function preflightHeaders(res) {
667
+ const out = {};
668
+ for (const [k, v] of Object.entries(res)) if (k.startsWith("access-control-") || k === "vary") out[k] = v;
669
+ return allowDutoHeader(out);
670
+ }
671
+ function allowDutoHeader(res) {
672
+ if (res["access-control-allow-origin"] === void 0) return res;
673
+ const raw = res["access-control-allow-headers"];
674
+ const cur = (Array.isArray(raw) ? raw.join(", ") : raw ?? "").trim();
675
+ const tokens = cur.split(",").map((t) => t.trim().toLowerCase());
676
+ if (tokens.includes("*") || tokens.includes("x-duto-auth")) return res;
677
+ return { ...res, "access-control-allow-headers": cur ? `${cur}, x-duto-auth` : "x-duto-auth" };
678
+ }
679
+ function cors401(origin) {
680
+ if (!origin) return {};
681
+ return { "access-control-allow-origin": origin, vary: "Origin", "access-control-expose-headers": "WWW-Authenticate" };
682
+ }
683
+
362
684
  // src/session/proxy.ts
363
685
  import { Agent, createServer, request } from "http";
364
686
  import { connect as connect2 } from "net";
@@ -386,16 +708,14 @@ var WAIT_SCRIPT = `
386
708
  es.onerror = function () { if (++fails >= 3) { es.close(); poll(); } };
387
709
  })();
388
710
  </script>`;
389
- function unavailablePage(o) {
390
- const tunnel2 = esc(o.tunnel);
391
- const local = `localhost:${o.port}`;
711
+ function pageShell(title, body, css = "") {
392
712
  return `<!doctype html>
393
713
  <html lang="pt-BR">
394
714
  <head>
395
715
  <meta charset="utf-8">
396
716
  <meta name="viewport" content="width=device-width, initial-scale=1">
397
717
  <meta name="robots" content="noindex">
398
- <title>Nada respondendo em ${local} \xB7 duto</title>
718
+ <title>${title}</title>
399
719
  <style>
400
720
  :root{--bg:#f6f6f3;--fg:#0f1110;--muted:#5f6360;--line:#dcddd8;--card:#fff;--signal:#2dd4bf;--signal-text:#0f766e}
401
721
  @media (prefers-color-scheme:dark){:root{--bg:#0b0d0c;--fg:#e9ebe7;--muted:#8a8f8b;--line:#222624;--card:#111413;--signal-text:#2dd4bf}}
@@ -410,19 +730,61 @@ code{font:13px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--fg)}
410
730
  h1 code{font-size:.86em;font-weight:600}
411
731
  .retry{margin-top:20px;padding-top:14px;border-top:1px solid var(--line);font-size:13px;color:var(--muted)}
412
732
  .retry b{color:var(--signal-text);font-weight:600}
413
- </style>
733
+ ${css}</style>
414
734
  </head>
415
735
  <body>
416
- <main>
736
+ ${body}
737
+ </body>
738
+ </html>
739
+ `;
740
+ }
741
+ function unavailablePage(o) {
742
+ const tunnel2 = esc(o.tunnel);
743
+ const local = `localhost:${o.port}`;
744
+ return pageShell(
745
+ `Nada respondendo em ${local} \xB7 duto`,
746
+ `<main>
417
747
  <div class="mark"><span class="dot"></span>duto \xB7 ${tunnel2}</div>
418
748
  <h1>Nada respondendo em <code>${local}</code></h1>
419
749
  <p>O t\xFAnel <code>${tunnel2}</code> est\xE1 no ar, mas o servi\xE7o local n\xE3o respondeu. Suba o servidor na m\xE1quina que abriu o t\xFAnel.</p>
420
750
  <p>Se voc\xEA s\xF3 recebeu este link, avise quem o enviou.</p>
421
751
  <div class="retry">${o.retry ? `<b>Aguardando o servi\xE7o.</b> A p\xE1gina volta sozinha quando ele responder.<noscript> Recarregue quando o servi\xE7o estiver no ar.</noscript>` : "Recarregue a p\xE1gina quando o servi\xE7o estiver no ar."}</div>
422
- </main>${o.retry ? WAIT_SCRIPT : ""}
423
- </body>
424
- </html>
752
+ </main>${o.retry ? WAIT_SCRIPT : ""}`
753
+ );
754
+ }
755
+
756
+ // src/session/authPage.ts
757
+ var CSS = `label{display:block;margin:14px 0 6px;font-size:13px;font-weight:600}
758
+ input{width:100%;padding:9px 10px;border:1px solid var(--line);border-radius:.3rem;background:var(--bg);color:var(--fg);font:inherit}
759
+ input:focus{outline:2px solid var(--signal);outline-offset:1px}
760
+ button{margin-top:18px;width:100%;padding:10px;border:0;border-radius:.3rem;background:var(--fg);color:var(--bg);font:inherit;font-weight:600;cursor:pointer}
761
+ .error{margin:14px 0 0;color:#b42318;font-weight:600}
762
+ @media (prefers-color-scheme:dark){.error{color:#f97066}}
425
763
  `;
764
+ function safeVolta(raw) {
765
+ if (!raw || !raw.startsWith("/") || raw.startsWith("//") || raw.startsWith("/\\")) return "/";
766
+ return /^[\x21-\x7e]+$/.test(raw) ? raw : "/";
767
+ }
768
+ function loginPage(o) {
769
+ return pageShell(
770
+ "Entrar \xB7 duto",
771
+ `<main>
772
+ <div class="mark"><span class="dot"></span>duto \xB7 ${esc(o.tunnel)}</div>
773
+ <h1>Este t\xFAnel tem senha</h1>
774
+ <p>Pe\xE7a o acesso a quem enviou o link.</p>
775
+ <form method="post" action="/__duto/entrar">
776
+ <input type="hidden" name="volta" value="${esc(o.volta)}">
777
+ <label for="usuario">Usu\xE1rio</label>
778
+ <input id="usuario" name="usuario" autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
779
+ <label for="senha">Senha</label>
780
+ <input id="senha" name="senha" type="password" autocomplete="current-password" required>
781
+ ${o.error ? `<p class="error" role="alert">Usu\xE1rio ou senha incorretos.</p>
782
+ ` : ""}<button type="submit">Entrar</button>
783
+ </form>
784
+ <div class="retry">Voc\xEA continua conectado at\xE9 o t\xFAnel fechar.</div>
785
+ </main>`,
786
+ CSS
787
+ );
426
788
  }
427
789
 
428
790
  // src/session/proxy.ts
@@ -506,6 +868,83 @@ function hostOf(req) {
506
868
  var DUTO_PREFIX = "/__duto/";
507
869
  var WAIT_PATH = "/__duto/espera";
508
870
  var WAIT_MAX_MS = 10 * 6e4;
871
+ var LOGIN_PATH = "/__duto/entrar";
872
+ var LOGOUT_PATH = "/__duto/sair";
873
+ var QR_PREFIX = "/__duto/qr/";
874
+ var MAX_FORM = 8 * 1024;
875
+ var DENIED_TEXT = "Este t\xFAnel tem senha. Envie Authorization: Basic ou X-Duto-Auth: usuario:senha.";
876
+ 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';
877
+ function readForm(req, max) {
878
+ return new Promise((resolve) => {
879
+ const chunks = [];
880
+ let size = 0;
881
+ const onData = (c) => {
882
+ size += c.length;
883
+ if (size > max) {
884
+ req.off("data", onData);
885
+ req.pause();
886
+ return resolve(null);
887
+ }
888
+ chunks.push(c);
889
+ };
890
+ req.on("data", onData);
891
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
892
+ req.on("error", () => resolve(null));
893
+ req.resume();
894
+ });
895
+ }
896
+ async function authRoute(req, res, route, auth) {
897
+ const url = new URL(req.url ?? "/", "http://duto");
898
+ const page = (status, volta, error) => {
899
+ res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
900
+ res.end(loginPage({ tunnel: route.tunnel, volta, error }));
901
+ };
902
+ const go = (location, cookie) => {
903
+ res.writeHead(303, { location, "cache-control": "no-store", ...cookie && { "set-cookie": cookie } });
904
+ res.end();
905
+ };
906
+ const { pathname } = url;
907
+ if (pathname === LOGIN_PATH && req.method === "GET") {
908
+ page(200, safeVolta(url.searchParams.get("volta")), false);
909
+ return true;
910
+ }
911
+ if (pathname === LOGIN_PATH && req.method === "POST") {
912
+ const raw = await readForm(req, MAX_FORM);
913
+ if (raw === null) {
914
+ res.writeHead(413, { "content-type": "text/plain; charset=utf-8", connection: "close" });
915
+ res.end("Formul\xE1rio grande demais");
916
+ return true;
917
+ }
918
+ const form = new URLSearchParams(raw);
919
+ const user = form.get("usuario") ?? "";
920
+ const volta = safeVolta(form.get("volta"));
921
+ if (auth.checkPassword(user, form.get("senha") ?? "")) go(volta, auth.loginCookie(user));
922
+ else page(401, volta, true);
923
+ return true;
924
+ }
925
+ if (pathname === LOGOUT_PATH && req.method === "GET") {
926
+ go(LOGIN_PATH, auth.logoutCookie());
927
+ return true;
928
+ }
929
+ if (pathname.startsWith(QR_PREFIX) && req.method === "GET") {
930
+ if (auth.consumeQrToken(pathname.slice(QR_PREFIX.length))) go("/", auth.loginCookie(auth.qrUser));
931
+ else go(LOGIN_PATH);
932
+ return true;
933
+ }
934
+ return false;
935
+ }
936
+ function deny(req, res, html) {
937
+ const status = html ? 303 : 401;
938
+ const out = html ? { location: `${LOGIN_PATH}?volta=${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) };
939
+ const body = html || req.method === "HEAD" ? "" : DENIED_TEXT;
940
+ res.writeHead(status, out);
941
+ res.end(body);
942
+ return { status, out, body };
943
+ }
944
+ function delivered(h, route) {
945
+ const out = rewrite(h, route);
946
+ return route.auth ? route.auth.strip(out) : out;
947
+ }
509
948
  async function isUp(port) {
510
949
  return await probe("127.0.0.1", port) || await probe("::1", port);
511
950
  }
@@ -538,23 +977,18 @@ function waitForService(res, port, checkMs, pingMs) {
538
977
  async function startProxy(o) {
539
978
  const max = o.maxBody ?? MAX_BODY;
540
979
  let nextId = 1;
541
- const server = createServer(async (req, res) => {
542
- const route = o.routes.get(hostOf(req));
543
- if (!route) {
544
- res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
545
- res.end("T\xFAnel n\xE3o encontrado nesta sess\xE3o");
546
- return;
547
- }
548
- if ((req.url ?? "").startsWith(DUTO_PREFIX)) {
549
- if (req.url === WAIT_PATH && req.method === "GET") return waitForService(res, route.port, o.waitCheckMs ?? 1e3, o.waitPingMs ?? 25e3);
550
- res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
551
- res.end("Rota reservada do Duto");
552
- return;
980
+ const routeWaitMs = o.routeWaitMs ?? 3e3;
981
+ async function routeFor(req) {
982
+ const host = hostOf(req);
983
+ for (let waited = 0; ; waited += 50) {
984
+ const route = o.routes.get(host);
985
+ if (route || waited >= routeWaitMs) return route;
986
+ await new Promise((r) => setTimeout(r, 50));
553
987
  }
988
+ }
989
+ function record(req, route, headers, websocket, access) {
554
990
  const id = nextId++;
555
991
  const started = Date.now();
556
- const headers = rewrite(req.headers, route);
557
- const reqCap = new Capture(TEXT.test(req.headers["content-type"] ?? ""), max);
558
992
  o.store.add({
559
993
  id,
560
994
  tunnel: route.tunnel,
@@ -564,18 +998,59 @@ async function startProxy(o) {
564
998
  durationMs: null,
565
999
  bytes: 0,
566
1000
  startedAt: started,
567
- websocket: false,
1001
+ websocket,
568
1002
  reqHeaders: flat(headers),
569
1003
  resHeaders: {},
570
1004
  reqBody: null,
571
1005
  resBody: null,
572
- truncated: false
1006
+ truncated: false,
1007
+ ...access && { access }
573
1008
  });
1009
+ return { id, started };
1010
+ }
1011
+ const server = createServer(async (req, res) => {
1012
+ req.pause();
1013
+ const route = await routeFor(req);
1014
+ if (!route) {
1015
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
1016
+ res.end("T\xFAnel n\xE3o encontrado nesta sess\xE3o");
1017
+ return;
1018
+ }
1019
+ if ((req.url ?? "").startsWith(DUTO_PREFIX)) {
1020
+ if (req.url === WAIT_PATH && req.method === "GET") {
1021
+ if (route.auth?.access(req.headers).kind === "denied") return void deny(req, res, false);
1022
+ return waitForService(res, route.port, o.waitCheckMs ?? 1e3, o.waitPingMs ?? 25e3);
1023
+ }
1024
+ if (route.auth && await authRoute(req, res, route, route.auth)) return;
1025
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
1026
+ res.end("Rota reservada do Duto");
1027
+ return;
1028
+ }
1029
+ const preflight = !!route.auth && isPreflight(req.method, req.headers);
1030
+ const access = route.auth && !preflight ? route.auth.access(req.headers) : void 0;
1031
+ const headers = delivered(req.headers, route);
1032
+ if (access?.kind === "denied") {
1033
+ const html = wantsHtml(req.headers.accept);
1034
+ const { id: id2, started: started2 } = record(req, route, headers, false, html ? void 0 : access);
1035
+ const { status, out, body } = deny(req, res, html);
1036
+ o.store.update(id2, { status, durationMs: Date.now() - started2, bytes: Buffer.byteLength(body), resHeaders: out });
1037
+ return;
1038
+ }
1039
+ const { id, started } = record(req, route, headers, false, access);
1040
+ const reqCap = new Capture(TEXT.test(req.headers["content-type"] ?? ""), max);
574
1041
  req.pause();
575
1042
  const host = await localHost(route.port);
576
1043
  const upstream = request(
577
1044
  { host, port: route.port, agent: LOCAL_AGENT, method: req.method, path: req.url, headers },
578
1045
  (up) => {
1046
+ if (preflight) {
1047
+ const out = preflightHeaders(up.headers);
1048
+ res.writeHead(up.statusCode ?? 502, out);
1049
+ res.end();
1050
+ up.on("end", () => o.store.update(id, { status: up.statusCode ?? null, durationMs: Date.now() - started, bytes: 0, resHeaders: flat(out) }));
1051
+ up.resume();
1052
+ return;
1053
+ }
579
1054
  const resCap = new Capture(TEXT.test(up.headers["content-type"] ?? ""), max);
580
1055
  let bytes = 0;
581
1056
  if (route.host && route.rewriteRedirects !== false && typeof up.headers.location === "string") {
@@ -614,32 +1089,26 @@ async function startProxy(o) {
614
1089
  } else res.destroy();
615
1090
  o.store.update(id, { status: 503, durationMs: Date.now() - started, bytes: res.headersSent ? 0 : Buffer.byteLength(msg), error: err.message || err.code || err.name, reqBody: reqCap.text() });
616
1091
  });
1092
+ if (preflight) {
1093
+ req.resume();
1094
+ upstream.end();
1095
+ return;
1096
+ }
617
1097
  req.on("data", (c) => reqCap.push(c));
618
1098
  req.pipe(upstream);
619
1099
  });
620
1100
  server.on("upgrade", async (req, client, head) => {
621
1101
  client.on("error", () => client.destroy());
622
- const route = o.routes.get(hostOf(req));
1102
+ const route = await routeFor(req);
623
1103
  if (!route) return void client.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
624
- const id = nextId++;
625
- const started = Date.now();
626
- const headers = rewrite(req.headers, route);
627
- o.store.add({
628
- id,
629
- tunnel: route.tunnel,
630
- method: req.method ?? "GET",
631
- path: req.url ?? "/",
632
- status: null,
633
- durationMs: null,
634
- bytes: 0,
635
- startedAt: started,
636
- websocket: true,
637
- reqHeaders: flat(headers),
638
- resHeaders: {},
639
- reqBody: null,
640
- resBody: null,
641
- truncated: false
642
- });
1104
+ const access = route.auth?.access(req.headers);
1105
+ const headers = delivered(req.headers, route);
1106
+ const { id, started } = record(req, route, headers, true, access);
1107
+ if (access?.kind === "denied") {
1108
+ client.end(WS_DENIED);
1109
+ o.store.update(id, { status: 401, durationMs: Date.now() - started });
1110
+ return;
1111
+ }
643
1112
  const target = connect2({ host: await localHost(route.port), port: route.port }, () => {
644
1113
  let raw = `${req.method} ${req.url} HTTP/${req.httpVersion}\r
645
1114
  `;
@@ -653,8 +1122,8 @@ async function startProxy(o) {
653
1122
  target.write(`${raw}\r
654
1123
  `);
655
1124
  if (head.length) target.write(head);
656
- target.once("data", (first) => {
657
- const status = Number(/^HTTP\/\d\.\d (\d{3})/.exec(first.toString("latin1"))?.[1] ?? 0) || null;
1125
+ target.once("data", (first2) => {
1126
+ const status = Number(/^HTTP\/\d\.\d (\d{3})/.exec(first2.toString("latin1"))?.[1] ?? 0) || null;
658
1127
  o.store.update(id, { status, durationMs: Date.now() - started });
659
1128
  });
660
1129
  target.pipe(client);
@@ -684,6 +1153,74 @@ async function startProxy(o) {
684
1153
  };
685
1154
  }
686
1155
 
1156
+ // src/session/orphans.ts
1157
+ import { spawnSync } from "child_process";
1158
+ import { existsSync as existsSync4, mkdirSync as mkdirSync4, readdirSync, readFileSync as readFileSync4, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
1159
+ import { join as join5 } from "path";
1160
+ var fileOf = (dir, cfPid) => join5(dir, `cloudflared-${cfPid}.json`);
1161
+ function recordCloudflared(dir, cfPid, sessionPid = process.pid) {
1162
+ try {
1163
+ mkdirSync4(dir, { recursive: true });
1164
+ writeFileSync4(fileOf(dir, cfPid), JSON.stringify({ sessionPid, cfPid, at: Date.now() }));
1165
+ } catch {
1166
+ }
1167
+ }
1168
+ function forgetCloudflared(dir, cfPid) {
1169
+ rmSync2(fileOf(dir, cfPid), { force: true });
1170
+ }
1171
+ var realProc = {
1172
+ alive(pid) {
1173
+ try {
1174
+ process.kill(pid, 0);
1175
+ return true;
1176
+ } catch (err) {
1177
+ return err.code === "EPERM";
1178
+ }
1179
+ },
1180
+ isCloudflared(pid) {
1181
+ try {
1182
+ if (process.platform === "linux") return readFileSync4(`/proc/${pid}/cmdline`, "utf8").includes("cloudflared");
1183
+ if (process.platform === "darwin") return spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" }).stdout.includes("cloudflared");
1184
+ } catch {
1185
+ return false;
1186
+ }
1187
+ return false;
1188
+ },
1189
+ kill(pid) {
1190
+ try {
1191
+ process.kill(pid, "SIGTERM");
1192
+ } catch {
1193
+ }
1194
+ }
1195
+ };
1196
+ function reapOrphans(dir, proc = realProc) {
1197
+ if (!existsSync4(dir)) return 0;
1198
+ let killed = 0;
1199
+ for (const name of readdirSync(dir)) {
1200
+ if (!/^cloudflared-\d+\.json$/.test(name)) continue;
1201
+ const path = join5(dir, name);
1202
+ let rec;
1203
+ try {
1204
+ rec = JSON.parse(readFileSync4(path, "utf8"));
1205
+ } catch {
1206
+ rmSync2(path, { force: true });
1207
+ continue;
1208
+ }
1209
+ const { sessionPid, cfPid } = rec;
1210
+ if (typeof sessionPid !== "number" || typeof cfPid !== "number") {
1211
+ rmSync2(path, { force: true });
1212
+ continue;
1213
+ }
1214
+ if (proc.alive(sessionPid)) continue;
1215
+ if (proc.alive(cfPid) && proc.isCloudflared(cfPid)) {
1216
+ proc.kill(cfPid);
1217
+ killed++;
1218
+ }
1219
+ rmSync2(path, { force: true });
1220
+ }
1221
+ return killed;
1222
+ }
1223
+
687
1224
  // src/session/records.ts
688
1225
  var RecordStore = class {
689
1226
  constructor(max = 500) {
@@ -744,6 +1281,9 @@ var Session = class {
744
1281
  records = new RecordStore();
745
1282
  tunnels = [];
746
1283
  routes = /* @__PURE__ */ new Map();
1284
+ specs = /* @__PURE__ */ new Map();
1285
+ /** Um TunnelAuth por túnel, reaproveitado nas reaberturas para não desconectar quem já entrou. */
1286
+ auths = /* @__PURE__ */ new Map();
747
1287
  proxy = null;
748
1288
  child = null;
749
1289
  token = null;
@@ -765,6 +1305,7 @@ var Session = class {
765
1305
  }
766
1306
  /** Abre os túneis em sequência; falhas ficam no estado e não interrompem os outros. */
767
1307
  async start(specs) {
1308
+ if (this.o.runDir) reapOrphans(this.o.runDir);
768
1309
  this.proxy = await startProxy({ routes: this.routes, store: this.records });
769
1310
  this.tunnels = specs.map((s) => this.blank(s, false));
770
1311
  this.emit();
@@ -787,6 +1328,26 @@ var Session = class {
787
1328
  const existing = await this.o.api.endpoints().catch(() => []);
788
1329
  await this.open(t, existing);
789
1330
  }
1331
+ /** Link para o QR: com senha, um token de uso único; sem senha, só a URL. */
1332
+ qrLink(key) {
1333
+ const t = this.tunnels.find((x) => x.key === key);
1334
+ if (!t?.url) return null;
1335
+ const auth = this.auths.get(key);
1336
+ if (!auth) return { url: t.url, expiresAt: null };
1337
+ const { token, expiresAt } = auth.issueQrToken();
1338
+ return { url: `${t.url}/__duto/qr/${token}`, expiresAt };
1339
+ }
1340
+ /** Cria o TunnelAuth na primeira abertura (precisa do allow resolvido) e o mantém depois. */
1341
+ async authFor(key) {
1342
+ const existing = this.auths.get(key);
1343
+ if (existing) return existing;
1344
+ const spec = this.specs.get(key);
1345
+ if (!spec?.auth?.length) return void 0;
1346
+ const allow = spec.allow?.length ? await (this.o.resolveAllow ?? resolveAllow)(spec.allow) : void 0;
1347
+ const auth = new TunnelAuth({ tunnel: key, credentials: spec.auth, allow });
1348
+ this.auths.set(key, auth);
1349
+ return auth;
1350
+ }
790
1351
  async stop() {
791
1352
  if (this.stopped) return;
792
1353
  this.stopped = true;
@@ -799,7 +1360,8 @@ var Session = class {
799
1360
  this.emit();
800
1361
  }
801
1362
  blank(spec, adHoc) {
802
- return { key: keyOf(spec), name: spec.name, port: spec.port, ...spec.host ? { host: spec.host } : {}, ...spec.headers ? { headers: spec.headers } : {}, ...spec.rewriteRedirects === false ? { rewriteRedirects: false } : {}, url: null, hostname: null, endpointId: null, status: "opening", error: null, adHoc, requests: 0, errors: 0 };
1363
+ this.specs.set(keyOf(spec), spec);
1364
+ return { key: keyOf(spec), name: spec.name, port: spec.port, ...spec.host ? { host: spec.host } : {}, ...spec.headers ? { headers: spec.headers } : {}, ...spec.rewriteRedirects === false ? { rewriteRedirects: false } : {}, ...spec.auth?.length ? { auth: true } : {}, url: null, hostname: null, endpointId: null, status: "opening", error: null, adHoc, requests: 0, errors: 0 };
803
1365
  }
804
1366
  async open(t, existing, retrying = false) {
805
1367
  if (this.stopped) return;
@@ -819,11 +1381,16 @@ var Session = class {
819
1381
  await withTimeout(this.o.api.closeTunnel(res.endpointId).catch(() => void 0), CLOSE_TIMEOUT_MS);
820
1382
  return;
821
1383
  }
1384
+ const auth = await this.authFor(t.key);
1385
+ if (this.stopped) {
1386
+ await withTimeout(this.o.api.closeTunnel(res.endpointId).catch(() => void 0), CLOSE_TIMEOUT_MS);
1387
+ return;
1388
+ }
822
1389
  t.url = res.url;
823
1390
  t.hostname = res.hostname;
824
1391
  t.endpointId = res.endpointId;
825
1392
  t.error = null;
826
- this.routes.set(res.hostname.toLowerCase(), { tunnel: t.key, port: t.port, host: t.host, headers: t.headers, rewriteRedirects: t.rewriteRedirects });
1393
+ this.routes.set(res.hostname.toLowerCase(), { tunnel: t.key, port: t.port, host: t.host, headers: t.headers, rewriteRedirects: t.rewriteRedirects, auth });
827
1394
  t.status = await isPortListening(t.port) ? "live" : "no-listener";
828
1395
  this.ensureCloudflared(res.tunnelToken);
829
1396
  this.emit();
@@ -853,6 +1420,11 @@ var Session = class {
853
1420
  stdio: ["ignore", "ignore", "pipe"]
854
1421
  });
855
1422
  this.child = c;
1423
+ const runDir = this.o.runDir;
1424
+ if (runDir && c.pid) {
1425
+ recordCloudflared(runDir, c.pid);
1426
+ c.once("exit", () => forgetCloudflared(runDir, c.pid));
1427
+ }
856
1428
  let buf = "";
857
1429
  c.stderr?.on("data", (chunk) => {
858
1430
  if (c !== this.child) return;
@@ -944,7 +1516,7 @@ function runPlain(session, out) {
944
1516
  for (const t of session.getState().tunnels) {
945
1517
  if ((t.status === "live" || t.status === "no-listener") && t.url && !announced.has(t.key)) {
946
1518
  announced.add(t.key);
947
- out(`${paint("signal", "\u25CF")} ${t.key} ${t.url} \u2192 ${localLabel(t)}`);
1519
+ out(`${paint("signal", "\u25CF")} ${t.key} ${t.url}${t.auth ? " \u{1F512}" : ""} \u2192 ${localLabel(t)}`);
948
1520
  }
949
1521
  if (t.status === "failed" && t.error && !failed.has(t.key)) {
950
1522
  failed.add(t.key);
@@ -994,6 +1566,23 @@ function parseHostFlag(raw) {
994
1566
  if (raw === void 0 || raw === true || raw === "") return void 0;
995
1567
  return parseHost(String(raw)) ?? fail2("Host inv\xE1lido: use s\xF3 o dom\xEDnio, como meuapp.test");
996
1568
  }
1569
+ function listFlag(raw) {
1570
+ if (raw === void 0) return [];
1571
+ return (Array.isArray(raw) ? raw : [raw]).map((v) => v === true ? "" : String(v));
1572
+ }
1573
+ function parseAuthFlags(raw) {
1574
+ const out = [];
1575
+ for (const entry of listFlag(raw)) {
1576
+ const r = parseCredential(entry);
1577
+ if (!r.ok) fail2(r.reason === "password" ? "Senha precisa ter de 8 a 128 caracteres" : r.reason === "user" ? "Usu\xE1rio inv\xE1lido: de 1 a 128 caracteres, sem espa\xE7o" : "Credencial inv\xE1lida: use usuario:senha");
1578
+ if (out.some((c) => c.user === r.cred.user)) fail2(`Usu\xE1rio "${r.cred.user}" aparece duas vezes`);
1579
+ out.push(r.cred);
1580
+ }
1581
+ return out;
1582
+ }
1583
+ function parseAllowFlags(raw) {
1584
+ return listFlag(raw).map((v) => parseAllowEntry(v) ? v : fail2(`"${v}" n\xE3o \xE9 um nome conhecido (stripe, github) nem uma faixa de IP`));
1585
+ }
997
1586
  function wantsUi(flags) {
998
1587
  if (flags.ui === false) return false;
999
1588
  if (!process.stdout.isTTY || !process.stdin.isTTY) return false;
@@ -1010,21 +1599,23 @@ async function runSession(specs, source, flags) {
1010
1599
  } catch (err) {
1011
1600
  fail2(err instanceof Error ? err.message : String(err));
1012
1601
  }
1013
- const session = new Session({ api, deviceId: config.deviceId, deviceName: hostname(), cloudflaredPath, source });
1602
+ const session = new Session({ api, deviceId: config.deviceId, deviceName: hostname(), cloudflaredPath, source, runDir: join6(dutoHome(), "run") });
1014
1603
  if (wantsUi(flags)) {
1015
- const { runFullscreen } = await import("./fullscreen-5437X7MP.js");
1016
- process.exit(await runFullscreen(session, specs, "0.4.5"));
1604
+ const { runFullscreen } = await import("./fullscreen-DKIFHB6J.js");
1605
+ process.exit(await runFullscreen(session, specs, "0.5.0"));
1017
1606
  }
1018
- console.log(`${mark()} ${paint("muted", `${"0.4.5"} \xB7 ${source} \xB7 Ctrl+C para encerrar`)}`);
1607
+ console.log(`${mark()} ${paint("muted", `${"0.5.0"} \xB7 ${source} \xB7 Ctrl+C para encerrar`)}`);
1019
1608
  const stopLog = runPlain(session, (l) => console.log(l));
1020
1609
  const stopped = new Promise((resolve) => {
1021
1610
  const onSignal = () => {
1022
1611
  process.off("SIGINT", onSignal);
1023
1612
  process.off("SIGTERM", onSignal);
1613
+ process.off("SIGHUP", onSignal);
1024
1614
  resolve();
1025
1615
  };
1026
1616
  process.on("SIGINT", onSignal);
1027
1617
  process.on("SIGTERM", onSignal);
1618
+ process.on("SIGHUP", onSignal);
1028
1619
  });
1029
1620
  await Promise.race([session.start(specs), stopped]);
1030
1621
  await stopped;
@@ -1035,6 +1626,8 @@ async function runSession(specs, source, flags) {
1035
1626
  async function tunnel(rawPort, flags) {
1036
1627
  if (rawPort === void 0) {
1037
1628
  if (flags.host !== void 0) fail2("Use --host junto com a porta (duto 8080 --host meuapp.test) ou host: no duto.yml");
1629
+ if (flags.auth !== void 0) fail2("Use --auth junto com a porta (duto 3000 --auth ana:senha) ou auth: no duto.yml");
1630
+ if (flags.permitir !== void 0) fail2("Use --permitir junto com a porta (duto 3000 --permitir stripe) ou permitir: no duto.yml");
1038
1631
  let found;
1039
1632
  try {
1040
1633
  found = findConfig(process.cwd());
@@ -1059,11 +1652,14 @@ ${paint("signal", "Dica:")} crie um duto.yml com os t\xFAneis do projeto e rode
1059
1652
  const host = parseHostFlag(flags.host);
1060
1653
  const keepRedirects = flags.rewriteRedirects === false;
1061
1654
  if (keepRedirects && !host) fail2("--no-rewrite-redirects s\xF3 vale junto com --host");
1062
- return runSession([{ name, port, ...host ? { host } : {}, ...keepRedirects ? { rewriteRedirects: false } : {} }], "linha de comando", flags);
1655
+ const auth = parseAuthFlags(flags.auth);
1656
+ const allow = parseAllowFlags(flags.permitir);
1657
+ if (allow.length && !auth.length) fail2("--permitir s\xF3 vale junto com --auth");
1658
+ return runSession([{ name, port, ...host ? { host } : {}, ...keepRedirects ? { rewriteRedirects: false } : {}, ...auth.length ? { auth } : {}, ...allow.length ? { allow } : {} }], "linha de comando", flags);
1063
1659
  }
1064
1660
  var cli = cac("duto");
1065
- cli.command("[port]", "Sem porta: abre os t\xFAneis do duto.yml. Com porta: abre um t\xFAnel para ela").option("--name <nome>", "Nome do endere\xE7o fixo (ex.: web \u2192 https://web-<usu\xE1rio>.duto.dev)").option("--host <host>", "Host que o servi\xE7o local recebe no lugar do p\xFAblico (ex.: meuapp.test)").option("--no-rewrite-redirects", "Com --host: n\xE3o troca o host local pelo p\xFAblico nos redirecionamentos").option("--no-ui", "Sem tela cheia: s\xF3 um log das requisi\xE7\xF5es (\xFAtil em scripts)").option("--ui", "For\xE7a a tela cheia (no Windows ela vem desligada)").action((port, opts) => tunnel(port, opts));
1066
- cli.command("http <port>", "O mesmo que duto <porta> (para quem vem do ngrok)").option("--name <nome>", "Nome do endere\xE7o fixo").option("--host <host>", "Host que o servi\xE7o local recebe no lugar do p\xFAblico").option("--no-rewrite-redirects", "Com --host: mant\xE9m os redirecionamentos como o servi\xE7o mandou").option("--no-ui", "Sem tela cheia").action((port, opts) => tunnel(port, opts));
1661
+ cli.command("[port]", "Sem porta: abre os t\xFAneis do duto.yml. Com porta: abre um t\xFAnel para ela").option("--name <nome>", "Nome do endere\xE7o fixo (ex.: web \u2192 https://web-<usu\xE1rio>.duto.dev)").option("--host <host>", "Host que o servi\xE7o local recebe no lugar do p\xFAblico (ex.: meuapp.test)").option("--no-rewrite-redirects", "Com --host: n\xE3o troca o host local pelo p\xFAblico nos redirecionamentos").option("--auth <usuario:senha>", "Pede usu\xE1rio e senha para abrir o endere\xE7o (repet\xEDvel)").option("--permitir <faixa>", "Com --auth: IP, faixa (CIDR) ou nome (stripe, github) que entra sem senha (repet\xEDvel)").option("--no-ui", "Sem tela cheia: s\xF3 um log das requisi\xE7\xF5es (\xFAtil em scripts)").option("--ui", "For\xE7a a tela cheia (no Windows ela vem desligada)").action((port, opts) => tunnel(port, opts));
1662
+ cli.command("http <port>", "O mesmo que duto <porta> (para quem vem do ngrok)").option("--name <nome>", "Nome do endere\xE7o fixo").option("--host <host>", "Host que o servi\xE7o local recebe no lugar do p\xFAblico").option("--no-rewrite-redirects", "Com --host: mant\xE9m os redirecionamentos como o servi\xE7o mandou").option("--auth <usuario:senha>", "Pede usu\xE1rio e senha para abrir o endere\xE7o (repet\xEDvel)").option("--permitir <faixa>", "Com --auth: IP, faixa (CIDR) ou nome (stripe, github) que entra sem senha (repet\xEDvel)").option("--no-ui", "Sem tela cheia").action((port, opts) => tunnel(port, opts));
1067
1663
  cli.command("login <token>", "Salva o authtoken (pegue em https://duto.dev)").action(async (token) => {
1068
1664
  try {
1069
1665
  const me = await createApiClient({ baseUrl: apiUrl(), token: String(token) }).me();
@@ -1102,7 +1698,7 @@ cli.help(
1102
1698
  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")
1103
1699
  }))
1104
1700
  );
1105
- cli.version("0.4.5");
1701
+ cli.version("0.5.0");
1106
1702
  try {
1107
1703
  cli.parse(process.argv, { run: false });
1108
1704
  await cli.runMatchedCommand();