localhostly 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +78 -2
- package/agente/cli.js +370 -0
- package/agente/servico/comum.js +41 -0
- package/agente/servico/index.js +100 -0
- package/agente/servico/launchd.js +100 -0
- package/agente/servico/systemd.js +115 -0
- package/agente/servico/windows.js +247 -0
- package/agente/tela.js +72 -0
- package/agente/tunel.js +356 -0
- package/bin/localhostly.js +5 -0
- package/lib/cabecalhos.js +63 -0
- package/lib/canal.js +87 -0
- package/lib/fluxo.js +100 -0
- package/lib/permitidos.js +85 -0
- package/lib/protocolo.js +96 -0
- package/package.json +38 -4
package/agente/tunel.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
// O agente: uma conexão de saída ao servidor e cada requisição reproduzida na porta local.
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import { performance } from 'node:perf_hooks';
|
|
5
|
+
import WebSocket from 'ws';
|
|
6
|
+
import { Canal } from '../lib/canal.js';
|
|
7
|
+
import { filtrar, paraObjeto, sem, valor } from '../lib/cabecalhos.js';
|
|
8
|
+
import { entregar, Remetente, segurarWs } from '../lib/fluxo.js';
|
|
9
|
+
import { codigoEnviavel, TIPO } from '../lib/protocolo.js';
|
|
10
|
+
|
|
11
|
+
export const SERVIDOR_PADRAO = 'wss://localhostly.iblabs.io/agent';
|
|
12
|
+
export const RECUO_MIN = 1_000;
|
|
13
|
+
export const RECUO_MAX = 60_000;
|
|
14
|
+
export const JITTER = 0.2;
|
|
15
|
+
|
|
16
|
+
// A espera antes da tentativa n (0 = a primeira depois da queda): min(max, min·2^n) ± 20 %, nunca acima de max
|
|
17
|
+
// (com a rede de volta, o túnel volta em até um minuto).
|
|
18
|
+
export function recuo(n, { min = RECUO_MIN, max = RECUO_MAX, jitter = JITTER, aleatorio = Math.random } = {}) {
|
|
19
|
+
const base = Math.min(max, min * 2 ** Math.min(n, 30));
|
|
20
|
+
return Math.round(Math.min(max, base * (1 + (aleatorio() * 2 - 1) * jitter)));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function paginaErro(alvo, erro) {
|
|
24
|
+
return (
|
|
25
|
+
'<!doctype html><html lang="en"><head><meta charset="utf-8"><title>502 · localhostly</title></head>' +
|
|
26
|
+
'<body style="font:16px/1.5 system-ui,sans-serif;max-width:36rem;margin:15vh auto;padding:0 1rem">' +
|
|
27
|
+
`<h1>502 · Local app unreachable</h1><p>localhostly could not reach <code>http://${alvo}</code>` +
|
|
28
|
+
` (${String(erro.code ?? erro.message).replace(/[<>&]/g, '')}). Is it running?</p></body></html>\n`
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function mesmaLista(a, b) {
|
|
33
|
+
return Array.isArray(a) && a.length === b.length && a.every((e, i) => e === b[i]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// alvo: { host, porta } do app local; hostCabecalho: o Host que o app recebe (null = o original).
|
|
37
|
+
export class Tunel extends EventEmitter {
|
|
38
|
+
constructor({
|
|
39
|
+
servidor = SERVIDOR_PADRAO,
|
|
40
|
+
token = null,
|
|
41
|
+
alvo,
|
|
42
|
+
hostCabecalho = null,
|
|
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,
|
|
46
|
+
reconectar = true,
|
|
47
|
+
recuoMin = RECUO_MIN,
|
|
48
|
+
recuoMax = RECUO_MAX,
|
|
49
|
+
silencioMs = 90_000,
|
|
50
|
+
handshakeMs = 15_000,
|
|
51
|
+
// a sessão de antes ({ nome, tokenSessao }): a primeira conexão já pede o mesmo nome
|
|
52
|
+
sessao = null,
|
|
53
|
+
// recusado pelo servidor (sem token, limite de túneis…): tenta de novo no recuo em vez de parar (o serviço)
|
|
54
|
+
insistir = false,
|
|
55
|
+
}) {
|
|
56
|
+
super();
|
|
57
|
+
this.servidor = servidor;
|
|
58
|
+
this.token = token;
|
|
59
|
+
this.alvo = alvo;
|
|
60
|
+
this.hostCabecalho = hostCabecalho;
|
|
61
|
+
this.senha = senha;
|
|
62
|
+
this.allow = Array.isArray(allow) && allow.length ? [...allow] : null;
|
|
63
|
+
this.reconectar = reconectar;
|
|
64
|
+
this.silencioMs = silencioMs;
|
|
65
|
+
this.handshakeMs = handshakeMs;
|
|
66
|
+
this.recuoMin = recuoMin;
|
|
67
|
+
this.recuoMax = recuoMax;
|
|
68
|
+
this.insistir = insistir;
|
|
69
|
+
this.tentativas = 0; // falhas seguidas desde o último "ready"
|
|
70
|
+
// { nome, tokenSessao } do último "ready": a reconexão retoma o mesmo nome
|
|
71
|
+
this.sessao = sessao?.nome && sessao?.tokenSessao ? { nome: sessao.nome, tokenSessao: sessao.tokenSessao } : null;
|
|
72
|
+
this.fechado = false;
|
|
73
|
+
this.nome = null;
|
|
74
|
+
this.url = null;
|
|
75
|
+
this.ws = null;
|
|
76
|
+
this.agenteHttp = new http.Agent({ keepAlive: true });
|
|
77
|
+
this.timerReconexao = null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get enderecoLocal() {
|
|
81
|
+
const h = this.alvo.host.includes(':') ? `[${this.alvo.host}]` : this.alvo.host;
|
|
82
|
+
return `${h}:${this.alvo.porta}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
abrir() {
|
|
86
|
+
this.#conectar();
|
|
87
|
+
return this;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Espera o primeiro "ready" (ou o erro do servidor).
|
|
91
|
+
pronto() {
|
|
92
|
+
if (this.nome) return Promise.resolve({ nome: this.nome, url: this.url });
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
const ok = (info) => {
|
|
95
|
+
this.off('recusado', falha);
|
|
96
|
+
resolve(info);
|
|
97
|
+
};
|
|
98
|
+
const falha = (erro) => {
|
|
99
|
+
this.off('pronto', ok);
|
|
100
|
+
reject(erro);
|
|
101
|
+
};
|
|
102
|
+
this.once('pronto', ok);
|
|
103
|
+
this.once('recusado', falha);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
fechar() {
|
|
108
|
+
this.fechado = true;
|
|
109
|
+
clearTimeout(this.timerReconexao);
|
|
110
|
+
this.ws?.terminate();
|
|
111
|
+
this.agenteHttp.destroy();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#conectar() {
|
|
115
|
+
// o prazo do aperto de mão: com a rede voltando, um SYN perdido não segura a tentativa por minutos
|
|
116
|
+
const ws = new WebSocket(this.servidor, {
|
|
117
|
+
perMessageDeflate: false,
|
|
118
|
+
maxPayload: 1024 * 1024,
|
|
119
|
+
handshakeTimeout: this.handshakeMs,
|
|
120
|
+
});
|
|
121
|
+
this.ws = ws;
|
|
122
|
+
const canal = new Canal(ws);
|
|
123
|
+
let recusado = false;
|
|
124
|
+
let aceito = false; // o "ready" valeu: só então as requisições chegam ao app local
|
|
125
|
+
const pedido = this.sessao?.nome ?? null;
|
|
126
|
+
let silencio = null;
|
|
127
|
+
const vigiar = () => {
|
|
128
|
+
clearTimeout(silencio);
|
|
129
|
+
silencio = setTimeout(() => ws.terminate(), this.silencioMs);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
ws.on('open', () => {
|
|
133
|
+
vigiar();
|
|
134
|
+
canal.enviarControle({
|
|
135
|
+
t: 'hello',
|
|
136
|
+
token: this.token,
|
|
137
|
+
nome: this.sessao?.nome ?? null,
|
|
138
|
+
tokenSessao: this.sessao?.tokenSessao ?? null,
|
|
139
|
+
host: this.hostCabecalho,
|
|
140
|
+
password: this.senha,
|
|
141
|
+
allow: this.allow,
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
ws.on('ping', vigiar);
|
|
145
|
+
ws.on('message', vigiar);
|
|
146
|
+
ws.on('error', (erro) => this.emit('aviso', erro));
|
|
147
|
+
canal.on('invalido', () => ws.terminate());
|
|
148
|
+
canal.on('controle', (msg) => {
|
|
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;
|
|
161
|
+
this.tentativas = 0;
|
|
162
|
+
this.nome = msg.nome;
|
|
163
|
+
this.url = msg.url;
|
|
164
|
+
this.sessao = msg.tokenSessao ? { nome: msg.nome, tokenSessao: msg.tokenSessao } : null;
|
|
165
|
+
// pediu um nome e veio outro (a reserva acabou, o servidor reiniciou): segue com o novo e avisa
|
|
166
|
+
if (pedido && pedido !== msg.nome) this.emit('nome_trocado', { de: pedido, para: msg.nome });
|
|
167
|
+
this.emit('pronto', {
|
|
168
|
+
nome: msg.nome,
|
|
169
|
+
url: msg.url,
|
|
170
|
+
retomado: Boolean(msg.retomado),
|
|
171
|
+
tokenSessao: msg.tokenSessao ?? null,
|
|
172
|
+
});
|
|
173
|
+
} else if (msg.t === 'erro') {
|
|
174
|
+
recusado = true;
|
|
175
|
+
const erro = new Error(msg.mensagem || msg.codigo);
|
|
176
|
+
erro.codigo = msg.codigo;
|
|
177
|
+
this.emit('recusado', erro);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
canal.aoAbrir = (quadro) => {
|
|
181
|
+
if (!aceito) return;
|
|
182
|
+
if (quadro.tipo === TIPO.REQ_INICIO) this.#requisicao(canal, quadro);
|
|
183
|
+
else this.#websocket(canal, quadro);
|
|
184
|
+
};
|
|
185
|
+
ws.on('close', () => {
|
|
186
|
+
clearTimeout(silencio);
|
|
187
|
+
const tinhaNome = this.nome;
|
|
188
|
+
this.nome = null;
|
|
189
|
+
this.url = null;
|
|
190
|
+
if (this.fechado) return;
|
|
191
|
+
if (tinhaNome) this.emit('caiu', { nome: tinhaNome });
|
|
192
|
+
if ((recusado && !this.insistir) || !this.reconectar) {
|
|
193
|
+
this.emit('fim');
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const espera = recuo(this.tentativas++, { min: this.recuoMin, max: this.recuoMax });
|
|
197
|
+
this.emit('reconectando', { emMs: espera, tentativa: this.tentativas });
|
|
198
|
+
this.timerReconexao = setTimeout(() => this.#conectar(), espera);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
#requisicao(canal, { id, payload }) {
|
|
203
|
+
const inicio = performance.now();
|
|
204
|
+
const { metodo, caminho, cabecalhos = [] } = payload;
|
|
205
|
+
const remetente = new Remetente(canal, id, TIPO.RES_CORPO, TIPO.RES_FIM);
|
|
206
|
+
let respondeu = false;
|
|
207
|
+
let acabou = false;
|
|
208
|
+
let status = 0;
|
|
209
|
+
|
|
210
|
+
const terminar = () => {
|
|
211
|
+
if (acabou) return;
|
|
212
|
+
acabou = true;
|
|
213
|
+
remetente.encerrar();
|
|
214
|
+
canal.soltar(id);
|
|
215
|
+
this.emit('requisicao', { metodo, caminho, status, ms: Math.round(performance.now() - inicio) });
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const local = http.request({
|
|
219
|
+
host: this.alvo.host,
|
|
220
|
+
port: this.alvo.porta,
|
|
221
|
+
method: metodo,
|
|
222
|
+
path: caminho,
|
|
223
|
+
headers: cabecalhos,
|
|
224
|
+
agent: this.agenteHttp,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
canal.registrar(id, {
|
|
228
|
+
quadro: ({ tipo, payload: p }) => {
|
|
229
|
+
if (tipo === TIPO.REQ_CORPO) entregar(local, p, canal, id);
|
|
230
|
+
else if (tipo === TIPO.REQ_FIM) local.end();
|
|
231
|
+
else if (tipo === TIPO.JANELA) remetente.credito(p);
|
|
232
|
+
else if (tipo === TIPO.ABORTAR) {
|
|
233
|
+
status = status || 499;
|
|
234
|
+
local.destroy();
|
|
235
|
+
terminar();
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
fechou: () => {
|
|
239
|
+
local.destroy();
|
|
240
|
+
terminar();
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
local.on('response', (res) => {
|
|
245
|
+
respondeu = true;
|
|
246
|
+
status = res.statusCode;
|
|
247
|
+
canal.enviar(TIPO.RES_INICIO, id, {
|
|
248
|
+
status: res.statusCode,
|
|
249
|
+
mensagem: res.statusMessage,
|
|
250
|
+
cabecalhos: filtrar(res.rawHeaders),
|
|
251
|
+
});
|
|
252
|
+
remetente.ligar(res);
|
|
253
|
+
res.on('end', terminar);
|
|
254
|
+
res.on('error', () => {
|
|
255
|
+
if (!acabou) canal.enviar(TIPO.ABORTAR, id, { motivo: 'local' });
|
|
256
|
+
terminar();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
local.on('error', (erro) => {
|
|
261
|
+
if (acabou) return;
|
|
262
|
+
if (!respondeu) {
|
|
263
|
+
status = 502;
|
|
264
|
+
const corpo = Buffer.from(paginaErro(this.enderecoLocal, erro));
|
|
265
|
+
canal.enviar(TIPO.RES_INICIO, id, {
|
|
266
|
+
status: 502,
|
|
267
|
+
cabecalhos: ['Content-Type', 'text/html; charset=utf-8', 'Content-Length', String(corpo.length)],
|
|
268
|
+
});
|
|
269
|
+
canal.enviar(TIPO.RES_CORPO, id, corpo);
|
|
270
|
+
canal.enviar(TIPO.RES_FIM, id);
|
|
271
|
+
} else {
|
|
272
|
+
canal.enviar(TIPO.ABORTAR, id, { motivo: 'local' });
|
|
273
|
+
}
|
|
274
|
+
terminar();
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
#websocket(canal, { id, payload }) {
|
|
279
|
+
const inicio = performance.now();
|
|
280
|
+
const { caminho = '/', cabecalhos = [] } = payload;
|
|
281
|
+
const protocolos = (valor(cabecalhos, 'sec-websocket-protocol') ?? '')
|
|
282
|
+
.split(',')
|
|
283
|
+
.map((p) => p.trim())
|
|
284
|
+
.filter(Boolean);
|
|
285
|
+
const headers = paraObjeto(sem(cabecalhos, ['sec-websocket-protocol']));
|
|
286
|
+
let aberto = false;
|
|
287
|
+
let acabou = false;
|
|
288
|
+
|
|
289
|
+
const terminar = (status) => {
|
|
290
|
+
if (acabou) return;
|
|
291
|
+
acabou = true;
|
|
292
|
+
canal.soltar(id);
|
|
293
|
+
if (!aberto) {
|
|
294
|
+
this.emit('requisicao', { metodo: 'WS', caminho, status, ms: Math.round(performance.now() - inicio) });
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const local = new WebSocket(`ws://${this.enderecoLocal}${caminho}`, protocolos, {
|
|
299
|
+
headers,
|
|
300
|
+
perMessageDeflate: false,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
canal.registrar(id, {
|
|
304
|
+
quadro: ({ tipo, payload: p }) => {
|
|
305
|
+
if (tipo === TIPO.WS_DADO) {
|
|
306
|
+
if (local.readyState === WebSocket.OPEN) local.send(p.dados, { binary: p.binario });
|
|
307
|
+
} else if (tipo === TIPO.WS_FECHAR) {
|
|
308
|
+
acabou = true;
|
|
309
|
+
canal.soltar(id);
|
|
310
|
+
if (codigoEnviavel(p.codigo)) local.close(p.codigo, String(p.motivo ?? '').slice(0, 120));
|
|
311
|
+
else local.close();
|
|
312
|
+
} else if (tipo === TIPO.ABORTAR) {
|
|
313
|
+
local.terminate();
|
|
314
|
+
terminar(499);
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
fechou: () => {
|
|
318
|
+
local.terminate();
|
|
319
|
+
terminar(499);
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
local.on('open', () => {
|
|
324
|
+
aberto = true;
|
|
325
|
+
canal.enviar(TIPO.RES_INICIO, id, { status: 101, protocolo: local.protocol || null });
|
|
326
|
+
this.emit('requisicao', { metodo: 'WS', caminho, status: 101, ms: Math.round(performance.now() - inicio) });
|
|
327
|
+
});
|
|
328
|
+
local.on('unexpected-response', (req, res) => {
|
|
329
|
+
if (!acabou) {
|
|
330
|
+
canal.enviar(TIPO.RES_INICIO, id, { status: res.statusCode, motivo: res.statusMessage });
|
|
331
|
+
terminar(res.statusCode);
|
|
332
|
+
}
|
|
333
|
+
res.resume();
|
|
334
|
+
req.destroy();
|
|
335
|
+
});
|
|
336
|
+
local.on('message', (dados, binario) => {
|
|
337
|
+
canal.enviar(TIPO.WS_DADO, id, { binario, dados: Buffer.isBuffer(dados) ? dados : Buffer.concat(dados) });
|
|
338
|
+
segurarWs(local, canal);
|
|
339
|
+
});
|
|
340
|
+
local.on('error', (erro) => {
|
|
341
|
+
if (acabou) return;
|
|
342
|
+
if (!aberto) canal.enviar(TIPO.RES_INICIO, id, { status: 502, motivo: String(erro.code ?? erro.message) });
|
|
343
|
+
else canal.enviar(TIPO.WS_FECHAR, id, { codigo: 1011, motivo: 'local error' });
|
|
344
|
+
terminar(502);
|
|
345
|
+
});
|
|
346
|
+
local.on('close', (codigo, motivo) => {
|
|
347
|
+
if (acabou) return;
|
|
348
|
+
canal.enviar(TIPO.WS_FECHAR, id, { codigo, motivo: motivo.toString('utf8') });
|
|
349
|
+
terminar(101);
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function abrirTunel(opcoes) {
|
|
355
|
+
return new Tunel(opcoes).abrir();
|
|
356
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Cabeçalhos em lista plana [nome, valor, nome, valor, ...], como o rawHeaders do Node:
|
|
2
|
+
// preserva repetidos (Set-Cookie) e a grafia original.
|
|
3
|
+
|
|
4
|
+
const SALTO = new Set([
|
|
5
|
+
'connection',
|
|
6
|
+
'keep-alive',
|
|
7
|
+
'proxy-authenticate',
|
|
8
|
+
'proxy-authorization',
|
|
9
|
+
'proxy-connection',
|
|
10
|
+
'te',
|
|
11
|
+
'trailer',
|
|
12
|
+
'transfer-encoding',
|
|
13
|
+
'upgrade',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
// Tira os de salto (hop-by-hop), os citados no Connection e os extras pedidos.
|
|
17
|
+
export function filtrar(lista, extras = []) {
|
|
18
|
+
const fora = new Set(extras.map((n) => n.toLowerCase()));
|
|
19
|
+
for (let i = 0; i < lista.length; i += 2) {
|
|
20
|
+
if (String(lista[i]).toLowerCase() === 'connection') {
|
|
21
|
+
for (const n of String(lista[i + 1]).split(',')) {
|
|
22
|
+
const nome = n.trim().toLowerCase();
|
|
23
|
+
if (nome) fora.add(nome);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const saida = [];
|
|
28
|
+
for (let i = 0; i + 1 < lista.length; i += 2) {
|
|
29
|
+
const nome = String(lista[i]);
|
|
30
|
+
const baixo = nome.toLowerCase();
|
|
31
|
+
if (SALTO.has(baixo) || fora.has(baixo)) continue;
|
|
32
|
+
saida.push(nome, String(lista[i + 1]));
|
|
33
|
+
}
|
|
34
|
+
return saida;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function valor(lista, nome) {
|
|
38
|
+
const alvo = nome.toLowerCase();
|
|
39
|
+
const achados = [];
|
|
40
|
+
for (let i = 0; i + 1 < lista.length; i += 2) {
|
|
41
|
+
if (String(lista[i]).toLowerCase() === alvo) achados.push(String(lista[i + 1]));
|
|
42
|
+
}
|
|
43
|
+
return achados.length ? achados.join(', ') : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function sem(lista, nomes) {
|
|
47
|
+
const fora = new Set(nomes.map((n) => n.toLowerCase()));
|
|
48
|
+
const saida = [];
|
|
49
|
+
for (let i = 0; i + 1 < lista.length; i += 2) {
|
|
50
|
+
if (!fora.has(String(lista[i]).toLowerCase())) saida.push(lista[i], lista[i + 1]);
|
|
51
|
+
}
|
|
52
|
+
return saida;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Lista plana -> objeto (repetidos juntados por vírgula), para APIs que só aceitam objeto.
|
|
56
|
+
export function paraObjeto(lista) {
|
|
57
|
+
const obj = {};
|
|
58
|
+
for (let i = 0; i + 1 < lista.length; i += 2) {
|
|
59
|
+
const nome = String(lista[i]).toLowerCase();
|
|
60
|
+
obj[nome] = nome in obj ? `${obj[nome]}, ${lista[i + 1]}` : String(lista[i + 1]);
|
|
61
|
+
}
|
|
62
|
+
return obj;
|
|
63
|
+
}
|
package/lib/canal.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// O canal: um WebSocket entre servidor e agente com vários fluxos (ids) em voo.
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import WebSocket from 'ws';
|
|
4
|
+
import { codificar, decodificar, TIPO } from './protocolo.js';
|
|
5
|
+
|
|
6
|
+
const ABRE_FLUXO = new Set([TIPO.REQ_INICIO, TIPO.WS_ABRIR]);
|
|
7
|
+
|
|
8
|
+
export class Canal extends EventEmitter {
|
|
9
|
+
constructor(ws) {
|
|
10
|
+
super();
|
|
11
|
+
this.ws = ws;
|
|
12
|
+
this.fluxos = new Map();
|
|
13
|
+
this.proximoId = 1;
|
|
14
|
+
this.aoAbrir = null; // (quadro) => void, no lado que recebe pedidos (o agente)
|
|
15
|
+
ws.on('message', (dados, binario) => this.#receber(dados, binario));
|
|
16
|
+
ws.on('close', () => this.#fechou());
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get aberto() {
|
|
20
|
+
return this.ws.readyState === WebSocket.OPEN;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get bufferedAmount() {
|
|
24
|
+
return this.ws.bufferedAmount;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
novoId() {
|
|
28
|
+
const id = this.proximoId;
|
|
29
|
+
this.proximoId = this.proximoId >= 0xffffffff ? 1 : this.proximoId + 1;
|
|
30
|
+
return id;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
registrar(id, fluxo) {
|
|
34
|
+
this.fluxos.set(id, fluxo);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
soltar(id) {
|
|
38
|
+
this.fluxos.delete(id);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
enviar(tipo, id, payload) {
|
|
42
|
+
if (!this.aberto) return false;
|
|
43
|
+
this.ws.send(codificar(tipo, id, payload), { binary: true });
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
enviarControle(msg) {
|
|
48
|
+
if (!this.aberto) return false;
|
|
49
|
+
this.ws.send(JSON.stringify(msg));
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#receber(dados, binario) {
|
|
54
|
+
if (!binario) {
|
|
55
|
+
let msg;
|
|
56
|
+
try {
|
|
57
|
+
msg = JSON.parse(dados.toString('utf8'));
|
|
58
|
+
} catch {
|
|
59
|
+
this.emit('invalido', new Error('controle que não é JSON'));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
this.emit('controle', msg);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
let quadro;
|
|
66
|
+
try {
|
|
67
|
+
quadro = decodificar(dados);
|
|
68
|
+
} catch (erro) {
|
|
69
|
+
this.emit('invalido', erro);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const fluxo = this.fluxos.get(quadro.id);
|
|
73
|
+
if (fluxo) {
|
|
74
|
+
fluxo.quadro(quadro);
|
|
75
|
+
} else if (ABRE_FLUXO.has(quadro.tipo) && this.aoAbrir) {
|
|
76
|
+
this.aoAbrir(quadro);
|
|
77
|
+
}
|
|
78
|
+
// quadro de id que já acabou (janela atrasada, corpo depois do abortar): ignorado
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#fechou() {
|
|
82
|
+
const fluxos = [...this.fluxos.values()];
|
|
83
|
+
this.fluxos.clear();
|
|
84
|
+
for (const fluxo of fluxos) fluxo.fechou?.();
|
|
85
|
+
this.emit('fechou');
|
|
86
|
+
}
|
|
87
|
+
}
|
package/lib/fluxo.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Controle de fluxo: o corpo nunca fica inteiro em memória.
|
|
2
|
+
//
|
|
3
|
+
// Por id: quem manda tem JANELA_INICIAL bytes de crédito; o receptor devolve o crédito
|
|
4
|
+
// (quadro JANELA) só quando o pedaço foi entregue ao destino (o callback do write),
|
|
5
|
+
// então um visitante lento ou um app local lento seguram só o próprio fluxo.
|
|
6
|
+
// No canal inteiro: a fonte pausa quando o WebSocket acumula mais de ALTO bytes no
|
|
7
|
+
// buffer de saída e retoma abaixo de BAIXO.
|
|
8
|
+
import { PEDACO_MAX, TIPO } from './protocolo.js';
|
|
9
|
+
|
|
10
|
+
export const JANELA_INICIAL = 2 * 1024 * 1024;
|
|
11
|
+
export const ALTO = 4 * 1024 * 1024;
|
|
12
|
+
export const BAIXO = 1024 * 1024;
|
|
13
|
+
const ESPERA_MS = 10;
|
|
14
|
+
|
|
15
|
+
export class Remetente {
|
|
16
|
+
constructor(canal, id, tipoCorpo, tipoFim) {
|
|
17
|
+
this.canal = canal;
|
|
18
|
+
this.id = id;
|
|
19
|
+
this.tipoCorpo = tipoCorpo;
|
|
20
|
+
this.tipoFim = tipoFim;
|
|
21
|
+
this.saldo = JANELA_INICIAL;
|
|
22
|
+
this.fonte = null;
|
|
23
|
+
this.parado = false;
|
|
24
|
+
this.encerrado = false;
|
|
25
|
+
this.timer = null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
ligar(fonte) {
|
|
29
|
+
this.fonte = fonte;
|
|
30
|
+
fonte.on('data', (pedaco) => this.#dados(pedaco));
|
|
31
|
+
fonte.on('end', () => {
|
|
32
|
+
if (!this.encerrado) this.canal.enviar(this.tipoFim, this.id);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
credito(n) {
|
|
37
|
+
this.saldo += n;
|
|
38
|
+
this.#tentarRetomar();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
encerrar() {
|
|
42
|
+
this.encerrado = true;
|
|
43
|
+
clearTimeout(this.timer);
|
|
44
|
+
this.timer = null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#dados(pedaco) {
|
|
48
|
+
if (this.encerrado) return;
|
|
49
|
+
for (let i = 0; i < pedaco.length; i += PEDACO_MAX) {
|
|
50
|
+
const parte = pedaco.subarray(i, i + PEDACO_MAX);
|
|
51
|
+
this.canal.enviar(this.tipoCorpo, this.id, parte);
|
|
52
|
+
this.saldo -= parte.length;
|
|
53
|
+
}
|
|
54
|
+
if (this.saldo <= 0 || this.canal.bufferedAmount > ALTO) {
|
|
55
|
+
if (!this.parado) {
|
|
56
|
+
this.parado = true;
|
|
57
|
+
this.fonte.pause();
|
|
58
|
+
}
|
|
59
|
+
this.#tentarRetomar();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
#tentarRetomar() {
|
|
64
|
+
if (!this.parado || this.encerrado || this.saldo <= 0) return;
|
|
65
|
+
if (this.canal.bufferedAmount > BAIXO) {
|
|
66
|
+
if (!this.timer) {
|
|
67
|
+
this.timer = setTimeout(() => {
|
|
68
|
+
this.timer = null;
|
|
69
|
+
this.#tentarRetomar();
|
|
70
|
+
}, ESPERA_MS);
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
this.parado = false;
|
|
75
|
+
this.fonte.resume();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Entrega um pedaço ao destino e devolve o crédito quando ele saiu.
|
|
80
|
+
export function entregar(destino, pedaco, canal, id) {
|
|
81
|
+
if (destino.destroyed || destino.writableEnded) return;
|
|
82
|
+
destino.write(pedaco, (erro) => {
|
|
83
|
+
if (!erro) canal.enviar(TIPO.JANELA, id, pedaco.length);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Mensagens de WebSocket: pausa a fonte enquanto o canal está cheio.
|
|
88
|
+
export function segurarWs(ws, canal) {
|
|
89
|
+
if (canal.bufferedAmount <= ALTO || ws.isPaused) return;
|
|
90
|
+
ws.pause();
|
|
91
|
+
const conferir = () => {
|
|
92
|
+
if (ws.readyState > 1) return;
|
|
93
|
+
if (canal.aberto && canal.bufferedAmount > BAIXO) {
|
|
94
|
+
setTimeout(conferir, ESPERA_MS);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
ws.resume();
|
|
98
|
+
};
|
|
99
|
+
setTimeout(conferir, ESPERA_MS);
|
|
100
|
+
}
|
|
@@ -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
|
+
}
|