@dutodev/cli 0.4.0 → 0.4.2

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 +34 -10
  2. package/dist/index.js +28 -20
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -51,24 +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
- - Na linha de comando: `duto 8080 --host loja.meuapp.test`.
71
- - Links que o app montar a partir do Host trocado 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.
72
95
 
73
96
  ## A tela
74
97
 
@@ -100,6 +123,7 @@ Fora de um terminal interativo (pipe, CI) ou com `--no-ui`, a CLI imprime um log
100
123
  |---|---|
101
124
  | `--name <nome>` | Endereço fixo `<nome>-<usuário>.duto.dev` |
102
125
  | `--host <host>` | Host que o serviço local recebe no lugar do público |
126
+ | `--no-rewrite-redirects` | Com `--host`, mantém os redirecionamentos como o serviço mandou |
103
127
  | `--no-ui` / `--ui` | Desliga / força a tela cheia |
104
128
 
105
129
  ## Configuração
package/dist/index.js CHANGED
@@ -240,7 +240,7 @@ var CONFIG_FILES = ["duto.yml", "duto.yaml"];
240
240
  var LABEL = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?";
241
241
  var HOST_RE = new RegExp(`^${LABEL}(?:\\.${LABEL})*(?::\\d{1,5})?$`);
242
242
  var HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
243
- var TUNNEL_KEYS = ["port", "host", "headers"];
243
+ var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects"];
244
244
  function parseHost(raw) {
245
245
  const host = raw.trim().toLowerCase();
246
246
  return HOST_RE.test(host) ? host : null;
@@ -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]);
@@ -282,7 +281,7 @@ function parseConfig(text, file = CONFIG_FILES[0]) {
282
281
  const body = pair.value;
283
282
  for (const item of body.items) {
284
283
  const key = isScalar(item.key) ? String(item.key.value) : "";
285
- if (!TUNNEL_KEYS.includes(key)) throw fail3(`chave desconhecida em "${raw}": ${key} (use port, host ou headers)`, line);
284
+ if (!TUNNEL_KEYS.includes(key)) throw fail3(`chave desconhecida em "${raw}": ${key} (use port, host, headers ou rewrite_redirects)`, line);
286
285
  }
287
286
  const hostNode = body.get("host", true);
288
287
  if (hostNode !== void 0) {
@@ -309,17 +308,15 @@ function parseConfig(text, file = CONFIG_FILES[0]) {
309
308
  }
310
309
  spec.headers = headers;
311
310
  }
312
- }
313
- const users = seen.get(port) ?? [];
314
- const clash = users.find((u) => !u.host || !spec.host || u.host === spec.host);
315
- if (clash) {
316
- throw fail3(
317
- 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.`,
318
- line
319
- );
311
+ const rewriteNode = body.get("rewrite_redirects", true);
312
+ if (rewriteNode !== void 0) {
313
+ const value = isScalar(rewriteNode) ? rewriteNode.value : void 0;
314
+ if (typeof value !== "boolean") throw fail3(`rewrite_redirects de "${raw}" deve ser true ou false`, line);
315
+ if (!spec.host) throw fail3(`rewrite_redirects de "${raw}" s\xF3 vale junto com host:`, line);
316
+ if (value === false) spec.rewriteRedirects = false;
317
+ }
320
318
  }
321
319
  if (specs.some((s) => s.name === name)) throw fail3(`o nome "${raw}" aparece duas vezes`, line);
322
- seen.set(port, [...users, { raw, host: spec.host }]);
323
320
  specs.push(spec);
324
321
  }
325
322
  return specs;
@@ -433,6 +430,12 @@ function rewrite(h, route) {
433
430
  }
434
431
  return out;
435
432
  }
433
+ var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
434
+ function rewriteLocation(location, localHost2, publicHost) {
435
+ const host = escapeRe(localHost2.replace(/:\d+$/, ""));
436
+ const re = new RegExp(`https?(://|%3A%2F%2F)${host}(?::\\d+|%3A\\d+)?(?=$|[/?#&]|%2F|%3F|%23|%26)`, "gi");
437
+ return location.replace(re, (_m, sep) => `https${sep}${publicHost}`);
438
+ }
436
439
  function hostOf(req) {
437
440
  return (req.headers.host ?? "").replace(/:\d+$/, "").toLowerCase();
438
441
  }
@@ -473,6 +476,9 @@ async function startProxy(o) {
473
476
  (up) => {
474
477
  const resCap = new Capture(TEXT.test(up.headers["content-type"] ?? ""), max);
475
478
  let bytes = 0;
479
+ if (route.host && route.rewriteRedirects !== false && typeof up.headers.location === "string") {
480
+ up.headers.location = rewriteLocation(up.headers.location, route.host, hostOf(req));
481
+ }
476
482
  res.writeHead(up.statusCode ?? 502, up.statusMessage, up.headers);
477
483
  up.on("data", (c) => {
478
484
  bytes += c.length;
@@ -690,7 +696,7 @@ var Session = class {
690
696
  this.emit();
691
697
  }
692
698
  blank(spec, adHoc) {
693
- return { key: keyOf(spec), name: spec.name, port: spec.port, ...spec.host ? { host: spec.host } : {}, ...spec.headers ? { headers: spec.headers } : {}, url: null, hostname: null, endpointId: null, status: "opening", error: null, adHoc, requests: 0, errors: 0 };
699
+ 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 };
694
700
  }
695
701
  async open(t, existing, retrying = false) {
696
702
  if (this.stopped) return;
@@ -714,7 +720,7 @@ var Session = class {
714
720
  t.hostname = res.hostname;
715
721
  t.endpointId = res.endpointId;
716
722
  t.error = null;
717
- this.routes.set(res.hostname.toLowerCase(), { tunnel: t.key, port: t.port, host: t.host, headers: t.headers });
723
+ this.routes.set(res.hostname.toLowerCase(), { tunnel: t.key, port: t.port, host: t.host, headers: t.headers, rewriteRedirects: t.rewriteRedirects });
718
724
  t.status = await isPortListening(t.port) ? "live" : "no-listener";
719
725
  this.ensureCloudflared(res.tunnelToken);
720
726
  this.emit();
@@ -904,9 +910,9 @@ async function runSession(specs, source, flags) {
904
910
  const session = new Session({ api, deviceId: config.deviceId, deviceName: hostname(), cloudflaredPath, source });
905
911
  if (wantsUi(flags)) {
906
912
  const { runFullscreen } = await import("./fullscreen-5437X7MP.js");
907
- process.exit(await runFullscreen(session, specs, "0.4.0"));
913
+ process.exit(await runFullscreen(session, specs, "0.4.2"));
908
914
  }
909
- console.log(`${mark()} ${paint("muted", `${"0.4.0"} \xB7 ${source} \xB7 Ctrl+C para encerrar`)}`);
915
+ console.log(`${mark()} ${paint("muted", `${"0.4.2"} \xB7 ${source} \xB7 Ctrl+C para encerrar`)}`);
910
916
  const stopLog = runPlain(session, (l) => console.log(l));
911
917
  const stopped = new Promise((resolve) => {
912
918
  const onSignal = () => {
@@ -948,11 +954,13 @@ ${paint("signal", "Dica:")} crie um duto.yml com os t\xFAneis do projeto e rode
948
954
  const port = parsePort(rawPort);
949
955
  const name = parseName(flags.name) ?? null;
950
956
  const host = parseHostFlag(flags.host);
951
- return runSession([{ name, port, ...host ? { host } : {} }], "linha de comando", flags);
957
+ const keepRedirects = flags.rewriteRedirects === false;
958
+ if (keepRedirects && !host) fail2("--no-rewrite-redirects s\xF3 vale junto com --host");
959
+ return runSession([{ name, port, ...host ? { host } : {}, ...keepRedirects ? { rewriteRedirects: false } : {} }], "linha de comando", flags);
952
960
  }
953
961
  var cli = cac("duto");
954
- 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-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));
955
- 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-ui", "Sem tela cheia").action((port, opts) => tunnel(port, opts));
962
+ 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));
963
+ 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));
956
964
  cli.command("login <token>", "Salva o authtoken (pegue em https://duto.dev)").action(async (token) => {
957
965
  try {
958
966
  const me = await createApiClient({ baseUrl: apiUrl(), token: String(token) }).me();
@@ -991,7 +999,7 @@ cli.help(
991
999
  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")
992
1000
  }))
993
1001
  );
994
- cli.version("0.4.0");
1002
+ cli.version("0.4.2");
995
1003
  try {
996
1004
  cli.parse(process.argv, { run: false });
997
1005
  await cli.runMatchedCommand();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dutodev/cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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",