@dutodev/cli 0.4.1 → 0.4.3

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.
Files changed (3) hide show
  1. package/README.md +35 -11
  2. package/dist/index.js +53 -16
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -51,25 +51,47 @@ Todos os túneis sobem juntos, numa única tela. A CLI procura o arquivo na past
51
51
 
52
52
  ### Host e cabeçalhos
53
53
 
54
- Por padrão, o serviço local recebe o Host público (`web-<usuário>.duto.dev`). Se ele escolhe o que responder pelo Host, como um proxy reverso local ou um app com vários domínios, troque o Host com `host:` e ajuste cabeçalhos com `headers:`:
54
+ Por padrão, o serviço local recebe a requisição como ela chegou da internet: `Host: web-<usuário>.duto.dev`, `X-Forwarded-Proto: https` e os cabeçalhos da Cloudflare (`CF-Connecting-IP` e outros). Na maioria dos apps isso basta. Quando o app decide algo pelo domínio, há duas ferramentas por túnel:
55
55
 
56
56
  ```yaml
57
57
  tunnels:
58
58
  loja:
59
59
  port: 8080
60
- host: loja.meuapp.test # o serviço recebe este Host
61
- painel:
62
- port: 8080 # mesma porta: vale quando cada túnel tem um host
63
- host: painel.meuapp.test
64
- headers:
65
- X-Ambiente: tunel # acrescenta ou sobrescreve
60
+ headers: # acrescenta ou sobrescreve cabeçalhos
61
+ X-Forwarded-Host: loja.meuapp.test
66
62
  X-Debug: "" # valor vazio remove o cabeçalho
63
+ painel:
64
+ port: 8081
65
+ host: painel.meuapp.test # troca o Host que o serviço recebe
67
66
  ```
68
67
 
69
- - Com `host:`, o `X-Forwarded-Host` não é enviado, para o app não ver o domínio público. Se quiser enviá-lo, coloque-o em `headers:`.
70
- - Redirecionamentos voltam com o endereço público: um `Location: http://loja.meuapp.test:8080/login` chega ao navegador como `https://loja-<usuário>.duto.dev/login`. Isso vale também dentro de parâmetros codificados, como o `redirect_uri` de um login OAuth. Para desligar, use `rewrite_redirects: false` no túnel.
71
- - Na linha de comando: `duto 8080 --host loja.meuapp.test` (e `--no-rewrite-redirects` para desligar a troca).
72
- - Links dentro das páginas (HTML, JSON) não são reescritos: se o app montar URLs a partir do Host, elas só abrem na sua máquina.
68
+ **Qual usar:**
69
+
70
+ | Situação | Use | Por quê |
71
+ |---|---|---|
72
+ | Um proxy reverso local (Traefik, Nginx, Caddy) escolhe o serviço pelo Host | `host:` | O proxy só conhece os domínios locais |
73
+ | Vários túneis na mesma porta, um por domínio | `host:` ou `headers:` diferentes em cada um | A porta pode se repetir; cada túnel tem o próprio endereço |
74
+ | O app identifica o domínio ou o cliente pelo `X-Forwarded-Host`, mas monta URLs a partir do `Host` | `headers:` com `X-Forwarded-Host` | O app vê o domínio local e continua gerando links públicos |
75
+ | Login OAuth em que o app calcula o `redirect_uri` sozinho | evite `host:`; tente `headers:` | Veja abaixo |
76
+
77
+ **O que o `host:` faz:**
78
+
79
+ - O serviço recebe o Host informado, e o `X-Forwarded-Host` não é enviado. Para enviá-lo assim mesmo, coloque-o em `headers:`.
80
+ - Os redirecionamentos voltam com o endereço público. Um `Location: http://painel.meuapp.test:8081/login` chega ao navegador como `https://painel-<usuário>.duto.dev/login`. Isso vale também dentro de parâmetros codificados no `Location`. Para desligar, use `rewrite_redirects: false` no túnel.
81
+ - Links no corpo das respostas (HTML, JSON) não são reescritos. URLs que o app montar a partir do Host só abrem na sua máquina.
82
+ - Na linha de comando: `duto 8081 --host painel.meuapp.test`, e `--no-rewrite-redirects` para desligar a troca.
83
+
84
+ **OAuth e `host:`.** No fluxo de autorização por código, o app manda um `redirect_uri` duas vezes: na ida do navegador ao provedor de login e depois, servidor a servidor, ao trocar o código por token. O provedor exige que as duas sejam idênticas. Com `host:`, o app gera as duas com o domínio local. O Duto corrige a primeira, que passa pelo navegador, mas não alcança a segunda, que não passa pelo túnel. O provedor recusa a troca, em geral com `invalid_request` ou `invalid_grant`, e o app costuma voltar ao login em loop (`ERR_TOO_MANY_REDIRECTS`).
85
+
86
+ Saídas, da mais simples para a mais trabalhosa:
87
+
88
+ 1. Não troque o Host. O app gera o `redirect_uri` com o endereço público nas duas vezes.
89
+ 2. Se o app precisa ver o domínio local para outras decisões e lê o `X-Forwarded-Host`, passe o domínio por `headers:` e mantenha o Host público.
90
+ 3. Configure no app a URL pública de retorno, se ele permitir.
91
+
92
+ Em todos os casos, cadastre `https://<nome>-<usuário>.duto.dev/<caminho de retorno>` como URL de retorno no provedor. A comparação costuma ser exata, sem curingas: cada pessoa do time tem o próprio endereço.
93
+
94
+ **Sobre o `X-Forwarded-*`.** `X-Forwarded-Host`, `X-Forwarded-Proto` e `X-Forwarded-For` são uma convenção antiga entre proxies reversos para contar ao app o que o cliente pediu de verdade. A versão padronizada é o cabeçalho `Forwarded` (RFC 7239), menos usado na prática. Frameworks costumam ler esses cabeçalhos só quando o proxy é marcado como confiável (`trusted proxies`, `trust proxy`, `USE_X_FORWARDED_HOST`), então o efeito de `headers:` depende de como o app está configurado. Por outro lado, se um app lê o `X-Forwarded-Host` sem essa checagem, qualquer cliente pode forjá-lo em produção. Vale garantir que o proxy da frente sobrescreva ou remova esse cabeçalho.
73
95
 
74
96
  ## A tela
75
97
 
@@ -84,6 +106,8 @@ Com os túneis no ar, o terminal mostra cada túnel (URL, destino local, requisi
84
106
  | `tab`, `↑↓` | Alterna entre túneis e requisições e move a seleção |
85
107
  | `q` ou Ctrl+C | Fecha os túneis e sai |
86
108
 
109
+ Se o túnel está no ar mas nada responde na porta local, quem abrir o endereço no navegador vê uma página do Duto que tenta de novo a cada 3 segundos e volta sozinha quando o serviço sobe. Clientes que não são navegador (`curl`, APIs) recebem um 502 com uma mensagem em texto.
110
+
87
111
  Fora de um terminal interativo (pipe, CI) ou com `--no-ui`, a CLI imprime um log simples. No Windows a tela cheia vem desligada; use `--ui` para tentar.
88
112
 
89
113
  ## Comandos
package/dist/index.js CHANGED
@@ -263,7 +263,6 @@ function parseConfig(text, file = CONFIG_FILES[0]) {
263
263
  throw fail3('declare os t\xFAneis em "tunnels:", um por linha (ex.: web: 3000).');
264
264
  }
265
265
  const specs = [];
266
- const seen = /* @__PURE__ */ new Map();
267
266
  for (const pair of tunnels.items) {
268
267
  const raw = isScalar(pair.key) ? String(pair.key.value) : "";
269
268
  const line = lineAt(pair.key?.range?.[0]);
@@ -317,16 +316,7 @@ function parseConfig(text, file = CONFIG_FILES[0]) {
317
316
  if (value === false) spec.rewriteRedirects = false;
318
317
  }
319
318
  }
320
- const users = seen.get(port) ?? [];
321
- const clash = users.find((u) => !u.host || !spec.host || u.host === spec.host);
322
- if (clash) {
323
- throw fail3(
324
- clash.host && clash.host === spec.host ? `a porta ${port} com host ${spec.host} aparece em "${clash.raw}" e em "${raw}"` : `a porta ${port} aparece em "${clash.raw}" e em "${raw}". Para usar a mesma porta, d\xEA um host a cada um.`,
325
- line
326
- );
327
- }
328
319
  if (specs.some((s) => s.name === name)) throw fail3(`o nome "${raw}" aparece duas vezes`, line);
329
- seen.set(port, [...users, { raw, host: spec.host }]);
330
320
  specs.push(spec);
331
321
  }
332
322
  return specs;
@@ -372,6 +362,52 @@ async function isPortListening(port, timeoutMs = 500) {
372
362
  // src/session/proxy.ts
373
363
  import { Agent, createServer, request } from "http";
374
364
  import { connect as connect2 } from "net";
365
+
366
+ // src/session/errorPage.ts
367
+ var esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
368
+ function wantsHtml(accept) {
369
+ return /\btext\/html\b/i.test(accept ?? "");
370
+ }
371
+ function unavailablePage(o) {
372
+ const tunnel2 = esc(o.tunnel);
373
+ const local = `localhost:${o.port}`;
374
+ return `<!doctype html>
375
+ <html lang="pt-BR">
376
+ <head>
377
+ <meta charset="utf-8">
378
+ <meta name="viewport" content="width=device-width, initial-scale=1">
379
+ ${o.retry ? '<meta http-equiv="refresh" content="3">\n' : ""}<meta name="robots" content="noindex">
380
+ <title>Nada respondendo em ${local} \xB7 duto</title>
381
+ <style>
382
+ :root{--bg:#f6f6f3;--fg:#0f1110;--muted:#5f6360;--line:#dcddd8;--card:#fff;--signal:#2dd4bf;--signal-text:#0f766e}
383
+ @media (prefers-color-scheme:dark){:root{--bg:#0b0d0c;--fg:#e9ebe7;--muted:#8a8f8b;--line:#222624;--card:#111413;--signal-text:#2dd4bf}}
384
+ *{box-sizing:border-box}
385
+ body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px 16px;background:var(--bg);color:var(--fg);font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}
386
+ main{width:100%;max-width:520px;border:1px solid var(--line);border-radius:.3rem;background:var(--card);padding:28px}
387
+ .mark{display:flex;align-items:center;gap:8px;font:600 13px/1 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.02em;color:var(--muted);margin-bottom:22px}
388
+ .dot{width:8px;height:8px;border-radius:50%;border:1.5px solid var(--signal)}
389
+ h1{font-size:21px;line-height:1.3;margin:0 0 10px;font-weight:650;letter-spacing:-.01em}
390
+ p{margin:0 0 12px;color:var(--muted)}
391
+ code{font:13px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--fg)}
392
+ h1 code{font-size:.86em;font-weight:600}
393
+ .retry{margin-top:20px;padding-top:14px;border-top:1px solid var(--line);font-size:13px;color:var(--muted)}
394
+ .retry b{color:var(--signal-text);font-weight:600}
395
+ </style>
396
+ </head>
397
+ <body>
398
+ <main>
399
+ <div class="mark"><span class="dot"></span>duto \xB7 ${tunnel2}</div>
400
+ <h1>Nada respondendo em <code>${local}</code></h1>
401
+ <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>
402
+ <p>Se voc\xEA s\xF3 recebeu este link, avise quem o enviou.</p>
403
+ <div class="retry">${o.retry ? "<b>Tentando de novo a cada 3 s.</b> A p\xE1gina volta sozinha quando o servi\xE7o responder." : "Recarregue a p\xE1gina quando o servi\xE7o estiver no ar."}</div>
404
+ </main>
405
+ </body>
406
+ </html>
407
+ `;
408
+ }
409
+
410
+ // src/session/proxy.ts
375
411
  var TEXT = /^(text\/|application\/(json|xml|javascript|x-www-form-urlencoded|graphql)|[^;]*\+(json|xml))/i;
376
412
  var MAX_BODY = 64 * 1024;
377
413
  var LOCAL_AGENT = new Agent({ keepAlive: false });
@@ -514,10 +550,11 @@ async function startProxy(o) {
514
550
  });
515
551
  upstream.on("error", (err) => {
516
552
  if (res.destroyed && res.writableFinished === false && !res.headersSent) return;
517
- const msg = `Nada escutando em localhost:${route.port}. Suba o servidor e tente de novo.`;
553
+ const html = wantsHtml(req.headers.accept);
554
+ const msg = html ? unavailablePage({ tunnel: route.tunnel, port: route.port, retry: req.method === "GET" }) : `Nada escutando em localhost:${route.port}. Suba o servidor e tente de novo.`;
518
555
  if (!res.headersSent) {
519
- res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
520
- res.end(msg);
556
+ res.writeHead(502, { "content-type": html ? "text/html; charset=utf-8" : "text/plain; charset=utf-8", "cache-control": "no-store" });
557
+ res.end(req.method === "HEAD" ? void 0 : msg);
521
558
  } else res.destroy();
522
559
  o.store.update(id, { status: 502, durationMs: Date.now() - started, bytes: res.headersSent ? 0 : Buffer.byteLength(msg), error: err.message || err.code || err.name, reqBody: reqCap.text() });
523
560
  });
@@ -920,9 +957,9 @@ async function runSession(specs, source, flags) {
920
957
  const session = new Session({ api, deviceId: config.deviceId, deviceName: hostname(), cloudflaredPath, source });
921
958
  if (wantsUi(flags)) {
922
959
  const { runFullscreen } = await import("./fullscreen-5437X7MP.js");
923
- process.exit(await runFullscreen(session, specs, "0.4.1"));
960
+ process.exit(await runFullscreen(session, specs, "0.4.3"));
924
961
  }
925
- console.log(`${mark()} ${paint("muted", `${"0.4.1"} \xB7 ${source} \xB7 Ctrl+C para encerrar`)}`);
962
+ console.log(`${mark()} ${paint("muted", `${"0.4.3"} \xB7 ${source} \xB7 Ctrl+C para encerrar`)}`);
926
963
  const stopLog = runPlain(session, (l) => console.log(l));
927
964
  const stopped = new Promise((resolve) => {
928
965
  const onSignal = () => {
@@ -1009,7 +1046,7 @@ cli.help(
1009
1046
  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")
1010
1047
  }))
1011
1048
  );
1012
- cli.version("0.4.1");
1049
+ cli.version("0.4.3");
1013
1050
  try {
1014
1051
  cli.parse(process.argv, { run: false });
1015
1052
  await cli.runMatchedCommand();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dutodev/cli",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Exponha uma porta local em https://<nome>-<usuário>.duto.dev com um comando.",
5
5
  "license": "MIT",
6
6
  "type": "module",