localhostly 1.0.0 → 1.1.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 CHANGED
@@ -35,6 +35,7 @@ localhostly 3000 # expose http://127.0.0.1:3000
35
35
  localhostly http 3000 --password # visitors must type a password (a random one is shown)
36
36
  localhostly http 3000 --password s3cret # ...or your own
37
37
  localhostly http 8080 --host myapp.test # forward to another local address; the app receives it as Host
38
+ localhostly http 3000 --allow 203.0.113.7,10.0.0.0/8 # only these IP addresses or ranges get in; others see 403
38
39
  ```
39
40
 
40
41
  The terminal shows the public URL and every request live. HTTP, streaming responses and WebSockets all go through.
@@ -43,6 +44,10 @@ Stop with Ctrl+C.
43
44
  The first time a browser opens your URL it sees a short warning page (it protects against phishing); API clients,
44
45
  webhooks and `curl` never see it, and a browser can skip it by sending the header `X-Localhostly-Skip-Warning`.
45
46
 
47
+ With `--allow` (IPv4 or IPv6 addresses and CIDR ranges, comma-separated or repeated, up to 64) only those
48
+ addresses reach your app, without the warning page; every other address gets a 403 page that shows it, WebSockets
49
+ included. `--allow` and `--password` together require both. `localhostly service install` takes `--allow` too.
50
+
46
51
  ## Keep it always on
47
52
 
48
53
  ```sh
@@ -56,7 +61,7 @@ with `npm i -g` first — a copy run by `npx` lives in a temporary folder.
56
61
 
57
62
  ## Free plan limits
58
63
 
59
- 2 tunnels at a time, 150 requests per minute and 25 GiB per month per account.
64
+ 2 tunnels at a time, 1000 requests per minute and 25 GiB per month per account.
60
65
 
61
66
  ## All options
62
67
 
package/agente/cli.js CHANGED
@@ -4,6 +4,7 @@ import { randomInt } from 'node:crypto';
4
4
  import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
5
  import { homedir } from 'node:os';
6
6
  import { join, resolve } from 'node:path';
7
+ import { ErroLista, normalizarLista, textoDaLista } from '../lib/permitidos.js';
7
8
  import * as servico from './servico/index.js';
8
9
  import { criarLog, criarTela } from './tela.js';
9
10
  import { SERVIDOR_PADRAO, Tunel } from './tunel.js';
@@ -30,6 +31,8 @@ Options:
30
31
  --password [value] ask visitors for a password (the browser shows a login box; any user name);
31
32
  without a value, a random one is created and shown here
32
33
  --password-file <file> the same, with the password read from a file
34
+ --allow <ip[,ip|cidr]> only these IP addresses or ranges can reach the tunnel; everyone else gets 403
35
+ (comma-separated, e.g. 203.0.113.7,10.0.0.0/8; use more than once to add)
33
36
  --service service mode: one log line per event, no live table, never gives up
34
37
  --home <dir> where the token and the service state live (default ~/.localhostly,
35
38
  or $LOCALHOSTLY_HOME)
@@ -90,6 +93,7 @@ export function lerAlvo(texto, portaPadrao) {
90
93
  }
91
94
 
92
95
  export function lerArgumentos(argv) {
96
+ const allow = [];
93
97
  const opcoes = {
94
98
  comando: null,
95
99
  posicionais: [],
@@ -97,6 +101,7 @@ export function lerArgumentos(argv) {
97
101
  servidor: null,
98
102
  senha: false,
99
103
  arquivoSenha: null,
104
+ allow: null, // a lista normalizada (textos), ou null sem --allow
100
105
  servico: false,
101
106
  home: null,
102
107
  };
@@ -128,11 +133,25 @@ export function lerArgumentos(argv) {
128
133
  else if (chave === '--server') opcoes.servidor = valorDe();
129
134
  else if (chave === '--password') opcoes.senha = valorDaSenha();
130
135
  else if (chave === '--password-file') opcoes.arquivoSenha = valorDe();
136
+ else if (chave === '--allow') {
137
+ const texto = valorDe();
138
+ if (!texto.trim()) throw erroDeUso(`${chave} needs a value: an IP address or range, e.g. --allow 203.0.113.7`);
139
+ allow.push(texto);
140
+ }
131
141
  else if (chave === '--service') opcoes.servico = true;
132
142
  else if (chave === '--home') opcoes.home = valorDe();
133
143
  else if (chave.startsWith('-')) throw erroDeUso(`unknown option: ${chave}`);
134
144
  else opcoes.posicionais.push(a);
135
145
  }
146
+ if (allow.length) {
147
+ try {
148
+ opcoes.allow = textoDaLista(normalizarLista(allow));
149
+ } catch (erro) {
150
+ if (!(erro instanceof ErroLista)) throw erro;
151
+ throw erroDeUso(`invalid --allow: ${erro.message} (use IP addresses or ranges, e.g. --allow 203.0.113.7,10.0.0.0/8)`);
152
+ }
153
+ if (!opcoes.allow.length) throw erroDeUso('--allow needs at least one IP address or range');
154
+ }
136
155
  if (!opcoes.comando) {
137
156
  const [primeiro] = opcoes.posicionais;
138
157
  if (COMANDOS.includes(primeiro)) opcoes.comando = opcoes.posicionais.shift();
@@ -169,7 +188,7 @@ function alvoDe(opcoes, uso) {
169
188
  }
170
189
 
171
190
  const USO_SERVICO =
172
- 'usage: localhostly service install <port> [--host h[:p]] [--password [value]] [--server url]\n' +
191
+ 'usage: localhostly service install <port> [--host h[:p]] [--password [value]] [--allow ip[,ip|cidr]] [--server url]\n' +
173
192
  ' localhostly service status\n' +
174
193
  ' localhostly service uninstall';
175
194
 
@@ -204,11 +223,12 @@ export async function comandoServico(
204
223
  }
205
224
  const servidor = opcoes.servidor || env.LOCALHOSTLY_SERVER || config.server || null;
206
225
  const antes = Date.now();
207
- const r = api.instalar({ porta, host: opcoes.host, servidor, senha, home }, { plataforma, env });
226
+ const r = api.instalar({ porta, host: opcoes.host, servidor, senha, allow: opcoes.allow, home }, { plataforma, env });
208
227
  saida.write(`Installed the localhostly ${nome}: ${r.arquivo}\n`);
209
228
  saida.write('It starts with the computer, reconnects by itself and keeps the same name.\n');
210
229
  if (gerada) saida.write(`Password: ${senha} (any user name)\n`);
211
230
  else if (senha) saida.write('Password protected (any user name).\n');
231
+ if (opcoes.allow) saida.write(`Allowed: ${opcoes.allow.join(', ')} (other addresses get 403)\n`);
212
232
  const fim = Date.now() + esperarUrlMs;
213
233
  let tunel = null;
214
234
  while (Date.now() < fim) {
@@ -306,6 +326,7 @@ export async function principal(argv = process.argv.slice(2), { env = process.en
306
326
  alvo,
307
327
  hostCabecalho: opcoes.host,
308
328
  senha,
329
+ allow: opcoes.allow,
309
330
  sessao: modoServico ? servico.lerEstado(home) : null,
310
331
  insistir: modoServico,
311
332
  });
@@ -318,7 +339,7 @@ export async function principal(argv = process.argv.slice(2), { env = process.en
318
339
  tela.aviso(`could not save the tunnel state in ${home}: ${erro.code ?? erro.message}`);
319
340
  }
320
341
  }
321
- tela.pronto({ url, alvo: tunel.enderecoLocal, senha: senha && (gerada ? senha : true), retomado });
342
+ tela.pronto({ url, alvo: tunel.enderecoLocal, senha: senha && (gerada ? senha : true), retomado, permitidos: opcoes.allow });
322
343
  });
323
344
  tunel.on('nome_trocado', ({ de, para }) =>
324
345
  tela.aviso(`name changed: ${de} -> ${para} (the old name was released while the tunnel was offline)`),
@@ -14,14 +14,16 @@ export function executar(programa, args = [], opcoes = {}) {
14
14
  }
15
15
 
16
16
  // O argv do agente que o serviço roda: [node, bin/localhostly.js, 'http', porta, '--service', ...]. A senha nunca vai
17
- // na linha de comando (o `ps` a mostraria a qualquer usuário): só o caminho do arquivo 0600 que a guarda.
18
- export function comandoDoAgente({ exe, porta, host = null, servidor = null, home, arquivoSenha = null }) {
17
+ // na linha de comando (o `ps` a mostraria a qualquer usuário): só o caminho do arquivo 0600 que a guarda. A lista
18
+ // do --allow (não é segredo) vai como "--allow a,b".
19
+ export function comandoDoAgente({ exe, porta, host = null, servidor = null, home, arquivoSenha = null, allow = null }) {
19
20
  if (!Array.isArray(exe) || exe.length === 0) throw new Error('exe: the agent command is missing');
20
21
  if (!Number.isInteger(porta) || porta < 1 || porta > 65535) throw new Error(`invalid port: ${porta}`);
21
22
  const args = [...exe, 'http', String(porta), '--service', '--home', home];
22
23
  if (host) args.push('--host', host);
23
24
  if (servidor) args.push('--server', servidor);
24
25
  if (arquivoSenha) args.push('--password-file', arquivoSenha);
26
+ if (allow?.length) args.push('--allow', allow.join(','));
25
27
  return args;
26
28
  }
27
29
 
@@ -61,11 +61,13 @@ export function gravarEstado(home, { nome, tokenSessao, url }, agora = new Date(
61
61
  return arquivo;
62
62
  }
63
63
 
64
- // opcoes: { porta, host, servidor, senha (texto ou null), home, exe }
64
+ // opcoes: { porta, host, servidor, senha (texto ou null), allow (a lista normalizada do --allow, ou null), home, exe }
65
65
  export function instalar(opcoes, { plataforma = process.platform, casa = homedir(), env = process.env, exec = executar, uid } = {}) {
66
66
  const s = sistema(plataforma);
67
- const { porta, host = null, servidor = null, senha = null, home, exe = exePadrao() } = opcoes;
68
- if (!Number.isInteger(porta)) throw new ErroServico('usage: localhostly service install <port> [--host h[:p]] [--password [value]]');
67
+ const { porta, host = null, servidor = null, senha = null, allow = null, home, exe = exePadrao() } = opcoes;
68
+ if (!Number.isInteger(porta)) {
69
+ throw new ErroServico('usage: localhostly service install <port> [--host h[:p]] [--password [value]] [--allow ip[,ip|cidr]]');
70
+ }
69
71
  mkdirSync(home, { recursive: true, mode: 0o700 });
70
72
  let arquivo = null;
71
73
  if (senha) {
@@ -77,7 +79,7 @@ export function instalar(opcoes, { plataforma = process.platform, casa = homedir
77
79
  } else {
78
80
  rmSync(arquivoSenha(home), { force: true });
79
81
  }
80
- const comando = comandoDoAgente({ exe, porta, host, servidor, home, arquivoSenha: arquivo });
82
+ const comando = comandoDoAgente({ exe, porta, host, servidor, home, arquivoSenha: arquivo, allow });
81
83
  const r = s.instalar({ comando, porta, home }, { casa, env, exec, home, ...(uid === undefined ? {} : { uid }) });
82
84
  // o npx roda de uma pasta de cache que pode sumir: o serviço apontaria para um caminho morto
83
85
  if (exe.some((parte) => /[\\/]_npx[\\/]/.test(parte))) {
package/agente/tela.js CHANGED
@@ -21,9 +21,11 @@ export function criarTela(saida = process.stdout, { cor = Boolean(saida.isTTY) &
21
21
  linha(pintar('cinza', `connecting to ${servidor}…`));
22
22
  },
23
23
  // senha: o texto (a gerada, que só esta tela mostra), true (a que o usuário escolheu) ou nada.
24
- pronto({ url, alvo, senha = null }) {
24
+ // permitidos: a lista do --allow (só esses endereços entram) ou nada.
25
+ pronto({ url, alvo, senha = null, permitidos = null }) {
25
26
  linha('');
26
27
  linha(`${pintar('negrito', url)} → http://${alvo}`);
28
+ if (permitidos?.length) linha(`Allowed: ${pintar('negrito', permitidos.join(', '))} ${pintar('cinza', '(other addresses get 403)')}`);
27
29
  if (typeof senha === 'string') linha(`Password: ${pintar('negrito', senha)} ${pintar('cinza', '(any user name)')}`);
28
30
  else if (senha) linha(pintar('cinza', 'Password protected (any user name).'));
29
31
  linha(pintar('cinza', 'Ctrl+C to stop. Requests:'));
@@ -53,8 +55,11 @@ export function criarLog(saida = process.stdout, { relogio = () => new Date() }
53
55
  conectando(servidor) {
54
56
  linha(`connecting to ${servidor}`);
55
57
  },
56
- pronto({ url, alvo, senha = null, retomado = false }) {
57
- linha(`online ${url} -> http://${alvo}${retomado ? ' (same name)' : ''}${senha ? ' (password protected)' : ''}`);
58
+ pronto({ url, alvo, senha = null, retomado = false, permitidos = null }) {
59
+ linha(
60
+ `online ${url} -> http://${alvo}${retomado ? ' (same name)' : ''}${senha ? ' (password protected)' : ''}` +
61
+ `${permitidos?.length ? ` (allowed: ${permitidos.join(',')})` : ''}`,
62
+ );
58
63
  },
59
64
  requisicao() {},
60
65
  aviso(texto) {
package/agente/tunel.js CHANGED
@@ -29,6 +29,10 @@ function paginaErro(alvo, erro) {
29
29
  );
30
30
  }
31
31
 
32
+ function mesmaLista(a, b) {
33
+ return Array.isArray(a) && a.length === b.length && a.every((e, i) => e === b[i]);
34
+ }
35
+
32
36
  // alvo: { host, porta } do app local; hostCabecalho: o Host que o app recebe (null = o original).
33
37
  export class Tunel extends EventEmitter {
34
38
  constructor({
@@ -37,6 +41,8 @@ export class Tunel extends EventEmitter {
37
41
  alvo,
38
42
  hostCabecalho = null,
39
43
  senha = null,
44
+ // a lista do --allow (textos normalizados por lib/permitidos.js), ou null: o túnel aberto a todos
45
+ allow = null,
40
46
  reconectar = true,
41
47
  recuoMin = RECUO_MIN,
42
48
  recuoMax = RECUO_MAX,
@@ -53,6 +59,7 @@ export class Tunel extends EventEmitter {
53
59
  this.alvo = alvo;
54
60
  this.hostCabecalho = hostCabecalho;
55
61
  this.senha = senha;
62
+ this.allow = Array.isArray(allow) && allow.length ? [...allow] : null;
56
63
  this.reconectar = reconectar;
57
64
  this.silencioMs = silencioMs;
58
65
  this.handshakeMs = handshakeMs;
@@ -114,6 +121,7 @@ export class Tunel extends EventEmitter {
114
121
  this.ws = ws;
115
122
  const canal = new Canal(ws);
116
123
  let recusado = false;
124
+ let aceito = false; // o "ready" valeu: só então as requisições chegam ao app local
117
125
  const pedido = this.sessao?.nome ?? null;
118
126
  let silencio = null;
119
127
  const vigiar = () => {
@@ -130,6 +138,7 @@ export class Tunel extends EventEmitter {
130
138
  tokenSessao: this.sessao?.tokenSessao ?? null,
131
139
  host: this.hostCabecalho,
132
140
  password: this.senha,
141
+ allow: this.allow,
133
142
  });
134
143
  });
135
144
  ws.on('ping', vigiar);
@@ -138,6 +147,17 @@ export class Tunel extends EventEmitter {
138
147
  canal.on('invalido', () => ws.terminate());
139
148
  canal.on('controle', (msg) => {
140
149
  if (msg.t === 'ready') {
150
+ // pediu --allow e o servidor não devolveu a mesma lista (o servidor de antes ignora o campo): o túnel
151
+ // estaria aberto a todos, então fecha em vez de seguir
152
+ if (this.allow && !mesmaLista(msg.allow, this.allow)) {
153
+ recusado = true;
154
+ const erro = new Error('this server does not support --allow; update the server or remove --allow');
155
+ erro.codigo = 'allow_unsupported';
156
+ this.emit('recusado', erro);
157
+ ws.terminate();
158
+ return;
159
+ }
160
+ aceito = true;
141
161
  this.tentativas = 0;
142
162
  this.nome = msg.nome;
143
163
  this.url = msg.url;
@@ -158,6 +178,7 @@ export class Tunel extends EventEmitter {
158
178
  }
159
179
  });
160
180
  canal.aoAbrir = (quadro) => {
181
+ if (!aceito) return;
161
182
  if (quadro.tipo === TIPO.REQ_INICIO) this.#requisicao(canal, quadro);
162
183
  else this.#websocket(canal, quadro);
163
184
  };
@@ -0,0 +1,85 @@
1
+ // A lista de endereços que podem visitar o túnel (--allow): IPs e faixas (CIDR), IPv4 e IPv6. O agente a confere
2
+ // antes de abrir e o servidor de novo no hello; os dois normalizam pela mesma regra, então o eco do "ready" é igual
3
+ // à lista que o agente mandou.
4
+ import net from 'node:net';
5
+
6
+ export const MAX_PERMITIDOS = 64;
7
+
8
+ export class ErroLista extends Error {
9
+ constructor(mensagem, entrada) {
10
+ super(mensagem);
11
+ this.entrada = entrada;
12
+ }
13
+ }
14
+
15
+ // "::ffff:1.2.3.4" (IPv4 mapeado em IPv6, como o socket de dupla pilha mostra) → "1.2.3.4".
16
+ function semMapeado(ip) {
17
+ const m = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip);
18
+ return m && net.isIPv4(m[1]) ? m[1] : ip;
19
+ }
20
+
21
+ // Um endereço na forma canônica: { ip, familia: 'ipv4' | 'ipv6' } ou null.
22
+ export function lerIp(texto) {
23
+ const ip = semMapeado(String(texto ?? '').trim());
24
+ const versao = net.isIP(ip);
25
+ if (versao === 4) return { ip, familia: 'ipv4' };
26
+ if (versao === 6) return { ip: new net.SocketAddress({ address: ip, family: 'ipv6' }).address, familia: 'ipv6' };
27
+ return null;
28
+ }
29
+
30
+ // Uma entrada: "203.0.113.7", "10.0.0.0/8", "2001:db8::/32" → { ip, bits, familia, texto }; inválida → null.
31
+ export function lerEntrada(texto) {
32
+ const t = String(texto ?? '').trim();
33
+ const m = /^([^/]+)(?:\/(\d{1,3}))?$/.exec(t);
34
+ if (!m) return null;
35
+ const end = lerIp(m[1]);
36
+ if (!end) return null;
37
+ const max = end.familia === 'ipv4' ? 32 : 128;
38
+ // o mapeado com prefixo de IPv6 (/96 + n) vira o prefixo de IPv4
39
+ let bits = m[2] === undefined ? max : Number(m[2]);
40
+ if (m[2] !== undefined && end.familia === 'ipv4' && m[1].includes(':')) bits -= 96;
41
+ if (!Number.isInteger(bits) || bits < 0 || bits > max) return null;
42
+ return { ip: end.ip, bits, familia: end.familia, texto: bits === max ? end.ip : `${end.ip}/${bits}` };
43
+ }
44
+
45
+ // texto ("a,b") ou lista de textos (cada um pode ter vírgulas) → a lista sem repetidas, na ordem; vazia → [].
46
+ // Lança ErroLista com a primeira entrada inválida ou com mais de MAX_PERMITIDOS.
47
+ export function normalizarLista(valor) {
48
+ const pedacos = (Array.isArray(valor) ? valor : [valor])
49
+ .flatMap((v) => String(v ?? '').split(','))
50
+ .map((p) => p.trim())
51
+ .filter(Boolean);
52
+ const lista = [];
53
+ const vistos = new Set();
54
+ for (const p of pedacos) {
55
+ const e = lerEntrada(p);
56
+ if (!e) throw new ErroLista(`not an IP address or range: ${p}`, p);
57
+ if (vistos.has(e.texto)) continue;
58
+ vistos.add(e.texto);
59
+ lista.push(e);
60
+ }
61
+ if (lista.length > MAX_PERMITIDOS) throw new ErroLista(`too many addresses: ${lista.length} (at most ${MAX_PERMITIDOS})`, null);
62
+ return lista;
63
+ }
64
+
65
+ export function textoDaLista(lista) {
66
+ return (lista ?? []).map((e) => e.texto);
67
+ }
68
+
69
+ // O conferidor de uma lista normalizada: (ip) → true se o endereço está nela.
70
+ export function criarConferidor(lista) {
71
+ const bloco = new net.BlockList();
72
+ for (const e of lista) {
73
+ const max = e.familia === 'ipv4' ? 32 : 128;
74
+ if (e.bits === max) bloco.addAddress(e.ip, e.familia);
75
+ else bloco.addSubnet(e.ip, e.bits, e.familia);
76
+ }
77
+ return (ip) => {
78
+ const end = lerIp(ip);
79
+ return Boolean(end) && bloco.check(end.ip, end.familia);
80
+ };
81
+ }
82
+
83
+ export function ipPermitido(ip, lista) {
84
+ return criarConferidor(lista)(ip);
85
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "localhostly",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Expose any local port on a public HTTPS URL, behind NAT, without opening ports.",
5
5
  "keywords": [
6
6
  "tunnel",
@@ -27,10 +27,14 @@
27
27
  },
28
28
  "scripts": {
29
29
  "test": "node --test \"testes/*.test.mjs\"",
30
- "servidor": "node server/index.js"
30
+ "servidor": "node server/index.js",
31
+ "seo:check": "node scripts/seo/check.mjs"
31
32
  },
32
33
  "license": "MIT",
33
34
  "dependencies": {
34
35
  "ws": "^8.22.2"
36
+ },
37
+ "devDependencies": {
38
+ "cheerio": "1.2.0"
35
39
  }
36
40
  }