terminal-smart-cli 0.98.0 → 0.99.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 +7 -5
- package/bin/ts.js +344 -196
- package/hub/cli.js +255 -0
- package/hub/controle.js +262 -0
- package/hub/dpapi.js +55 -0
- package/hub/estado.js +274 -0
- package/hub/exportar-telas.js +149 -0
- package/hub/hub.js +717 -0
- package/hub/mascara.js +67 -0
- package/hub/parar.js +151 -0
- package/hub/principal.js +5 -0
- package/hub/proto/PROTOCOLO.md +789 -0
- package/hub/proto/ts-proto.js +1643 -0
- package/hub/relay-conexao.js +200 -0
- package/hub/telas-arquivo.js +130 -0
- package/hub/tmux.js +189 -0
- package/hub/vps/README.md +109 -0
- package/hub/vps/tmux-agentes.conf +5 -0
- package/hub/vps/ts-hub-exportar-telas.service +30 -0
- package/hub/vps/ts-hub-parar-consumir +67 -0
- package/hub/vps/ts-hub-parar.path +16 -0
- package/hub/vps/ts-hub-parar.service +23 -0
- package/hub/vps/ts-hub-parar.tmpfiles.conf +10 -0
- package/hub/vps/ts-hub.service +39 -0
- package/hub/vps/ts-tmux.service +19 -0
- package/lib/acp.js +8 -2
- package/lib/agent.js +186 -87
- package/lib/chat-local.js +139 -0
- package/lib/core.js +336 -23
- package/lib/doctor.js +13 -5
- package/lib/i18n.js +69 -31
- package/lib/ia-local.js +388 -0
- package/lib/intelligence-core.js +108 -1
- package/lib/keyring.js +0 -1
- package/lib/meta.js +8 -8
- package/lib/personal-agent-llm.js +99 -26
- package/lib/sentinela.js +2 -2
- package/lib/tools.js +638 -8
- package/package.json +4 -2
package/hub/cli.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Subcomandos `ts hub ...` (roteados por bin/ts.js).
|
|
3
|
+
//
|
|
4
|
+
// ts hub iniciar processo de longa duração (systemd na VPS, usuário tshub)
|
|
5
|
+
// ts hub parear QR de uso único (5 min); confirmação DIGITANDO no PC os 8 primeiros
|
|
6
|
+
// caracteres da impressão digital mostrada no celular
|
|
7
|
+
// ts hub aparelhos lista os aparelhos autorizados
|
|
8
|
+
// ts hub revogar <id> tira o aparelho da lista e derruba a sessão dele na hora
|
|
9
|
+
// ts hub status estado do daemon
|
|
10
|
+
// ts hub exportar-telas (usuário dos agentes, não o hub) grava as telas do tmux para o hub
|
|
11
|
+
//
|
|
12
|
+
// Variáveis: TS_HUB_DIR (padrão ~/.ts/hub), TS_HUB_RELAY (padrão
|
|
13
|
+
// https://relay.terminalsmart.com.br), TS_HUB_PWA_URL (origem da PWA no QR; padrão = celular.terminalsmart.com.br com o relay oficial),
|
|
14
|
+
// TS_HUB_PARAR_DIR (diretório do Parar tudo; sem ele, indisponível), TS_HUB_TELAS_DIR
|
|
15
|
+
// (instantâneos de tela), TS_HUB_NOME.
|
|
16
|
+
|
|
17
|
+
const readline = require('readline');
|
|
18
|
+
const estado = require('./estado');
|
|
19
|
+
const { conectarControle } = require('./controle');
|
|
20
|
+
|
|
21
|
+
const AJUDA = [
|
|
22
|
+
'Uso: ts hub <subcomando>',
|
|
23
|
+
'',
|
|
24
|
+
' iniciar inicia o hub (conexão de saída com o relay; deixe rodando)',
|
|
25
|
+
' parear [--mostrar-url] mostra o QR para parear um celular (confirmação digitando aqui no PC)',
|
|
26
|
+
' aparelhos lista os aparelhos autorizados',
|
|
27
|
+
' revogar <id> revoga um aparelho (corta a sessão na hora)',
|
|
28
|
+
' status estado do hub',
|
|
29
|
+
' exportar-telas [--uma-vez] (como o usuário dos agentes) grava as telas do tmux para o hub ler',
|
|
30
|
+
'',
|
|
31
|
+
'Variáveis: TS_HUB_DIR, TS_HUB_RELAY, TS_HUB_PWA_URL, TS_HUB_PARAR_DIR, TS_HUB_TELAS_DIR, TS_HUB_NOME',
|
|
32
|
+
' exportar-telas: TS_HUB_TELAS_DIR, TS_HUB_TELAS_PERMITIDAS, TS_HUB_TELAS_HISTORICO',
|
|
33
|
+
].join('\n');
|
|
34
|
+
|
|
35
|
+
const MENSAGENS_ERRO = {
|
|
36
|
+
E_LIMITE: 'limite atingido (aparelhos ou convites abertos)',
|
|
37
|
+
E_PAREAMENTO_ESTADO: 'pareamento fora de ordem ou encerrado',
|
|
38
|
+
E_CONTROLE_NEGADO: 'o hub recusou o controle',
|
|
39
|
+
E_CONFIRMACAO: 'o código não confere com a impressão digital do celular',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
function falhar(msg, codigo) {
|
|
43
|
+
console.error(' ✗ ' + msg);
|
|
44
|
+
process.exitCode = codigo || 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function hora(ms) {
|
|
48
|
+
return new Date(ms).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function logDaemon(msg) {
|
|
52
|
+
// Só ID, tipo, estado e código: nunca segredo, URL de convite ou conteúdo de tela.
|
|
53
|
+
console.log(new Date().toISOString() + ' ts-hub: ' + msg);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function iniciar() {
|
|
57
|
+
const { Hub } = require('./hub');
|
|
58
|
+
const { abrirControle } = require('./controle');
|
|
59
|
+
const dir = estado.dirPadrao();
|
|
60
|
+
let hub, controle;
|
|
61
|
+
try {
|
|
62
|
+
hub = await Hub.criar({ dir, log: logDaemon });
|
|
63
|
+
controle = await abrirControle(hub);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
if (hub) hub.parar();
|
|
66
|
+
return falhar(e.message);
|
|
67
|
+
}
|
|
68
|
+
if (hub.chaveCriada) logDaemon('chave do nó gerada');
|
|
69
|
+
logDaemon('nó ' + hub.id + ' (impressão ' + hub.impressao + ') · relay ' + hub.relay + ' · estado ' + dir);
|
|
70
|
+
logDaemon('terminais: ' + (hub.provedor.nome || '?') + ' · parar tudo: ' + (hub.pararDisponivel ? 'disponível' : 'indisponível neste nó'));
|
|
71
|
+
hub.iniciar();
|
|
72
|
+
await new Promise(resolve => {
|
|
73
|
+
let saindo = false;
|
|
74
|
+
const sair = async sinal => {
|
|
75
|
+
if (saindo) return;
|
|
76
|
+
saindo = true;
|
|
77
|
+
logDaemon('encerrando (' + sinal + ')');
|
|
78
|
+
hub.parar();
|
|
79
|
+
await controle.fechar();
|
|
80
|
+
resolve();
|
|
81
|
+
};
|
|
82
|
+
process.on('SIGINT', () => sair('SIGINT'));
|
|
83
|
+
process.on('SIGTERM', () => sair('SIGTERM'));
|
|
84
|
+
});
|
|
85
|
+
process.exit(0);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function comControle(fn) {
|
|
89
|
+
let c;
|
|
90
|
+
try { c = await conectarControle(estado.dirPadrao()); } catch (e) { return falhar(e.message, 2); }
|
|
91
|
+
try { return await fn(c); } catch (e) {
|
|
92
|
+
return falhar(MENSAGENS_ERRO[e.codigo] || e.message);
|
|
93
|
+
} finally { c.fechar(); }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function exigirOk(r) {
|
|
97
|
+
if (!r.ok) throw Object.assign(new Error(MENSAGENS_ERRO[r.erro] || ('o hub recusou (' + r.erro + ')')), { codigo: r.erro });
|
|
98
|
+
return r;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function status(json) {
|
|
102
|
+
return comControle(async c => {
|
|
103
|
+
const { status: s } = exigirOk(await c.pedir({ op: 'status' }));
|
|
104
|
+
if (json) return console.log(JSON.stringify(s));
|
|
105
|
+
console.log('');
|
|
106
|
+
console.log(' ts hub — ' + s.nome);
|
|
107
|
+
console.log(' nó ' + s.id + ' impressão ' + s.impressao);
|
|
108
|
+
console.log(' relay ' + s.relay + ' (' + s.relayEstado + ')');
|
|
109
|
+
console.log(' aparelhos ' + s.aparelhos + ' sessões ativas ' + s.sessoes);
|
|
110
|
+
console.log(' terminais ' + (s.provedorTerminais || '?'));
|
|
111
|
+
console.log(' parar tudo ' + (s.pararDisponivel ? 'disponível' : 'indisponível neste nó (sem TS_HUB_PARAR_DIR/consumidor)'));
|
|
112
|
+
console.log(' parado ' + (s.parado ? 'SIM (confirmado pelo consumidor)' : 'não'));
|
|
113
|
+
console.log(' no ar há ' + s.tempoNoArSeg + ' s versão ' + s.versao);
|
|
114
|
+
console.log('');
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function aparelhos(json) {
|
|
119
|
+
return comControle(async c => {
|
|
120
|
+
const { aparelhos: lista } = exigirOk(await c.pedir({ op: 'aparelhos' }));
|
|
121
|
+
if (json) return console.log(JSON.stringify(lista));
|
|
122
|
+
if (!lista.length) return console.log(' Nenhum aparelho pareado. Use: ts hub parear');
|
|
123
|
+
console.log('');
|
|
124
|
+
for (const a of lista) {
|
|
125
|
+
console.log(' ' + a.id + ' ' + a.impressao + ' ' + a.nome + (a.sessoes ? ' (' + a.sessoes + ' sessão ativa)' : ''));
|
|
126
|
+
console.log(' pareado em ' + a.pareadoEm);
|
|
127
|
+
}
|
|
128
|
+
console.log('');
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function revogar(id) {
|
|
133
|
+
if (!id || !/^[A-Za-z0-9_-]{22}$/.test(id)) return falhar('informe o ID do aparelho (veja: ts hub aparelhos)', 2);
|
|
134
|
+
return comControle(async c => {
|
|
135
|
+
const r = exigirOk(await c.pedir({ op: 'revogar', id }));
|
|
136
|
+
if (!r.revogado) return falhar('aparelho não encontrado: ' + id);
|
|
137
|
+
console.log(' ✔ aparelho revogado; sessões dele derrubadas e nova lista publicada.');
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function perguntar(rl, texto) {
|
|
142
|
+
return new Promise(resolve => rl.question(texto, r => resolve(r)));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function parear(mostrarUrl) {
|
|
146
|
+
return comControle(async c => {
|
|
147
|
+
exigirOk(await c.pedir({ op: 'parear' }));
|
|
148
|
+
const conv = await c.receber(m => m.evento === 'convite' || m.evento === 'fim', 15000);
|
|
149
|
+
if (conv.evento === 'fim') return falhar('convite encerrado: ' + conv.estado);
|
|
150
|
+
let qr = null;
|
|
151
|
+
try { qr = require('qrcode-terminal'); } catch (_) {}
|
|
152
|
+
if (!qr && !mostrarUrl) return falhar('não consegui desenhar o QR (qrcode-terminal ausente). Para ver o endereço: ts hub parear --mostrar-url');
|
|
153
|
+
console.log('');
|
|
154
|
+
if (qr) qr.generate(conv.url, { small: true }, q => console.log(q.split('\n').map(l => ' ' + l).join('\n')));
|
|
155
|
+
console.log(' Leia o QR com a câmera do celular.');
|
|
156
|
+
// A URL tem o segredo do convite: só na tela, e só a pedido (nada de log ou arquivo).
|
|
157
|
+
if (mostrarUrl) {
|
|
158
|
+
console.log(' ATENÇÃO: o endereço abaixo contém o segredo do convite. Quem o tiver nos próximos');
|
|
159
|
+
console.log(' minutos pode pedir o pareamento. Não cole em chat, e-mail ou documento.');
|
|
160
|
+
console.log(' ' + conv.url);
|
|
161
|
+
}
|
|
162
|
+
console.log(' Válido até ' + hora(conv.exp) + ' (uso único).' + (conv.conectado ? '' : ' Atenção: o hub ainda não está conectado ao relay.'));
|
|
163
|
+
console.log('');
|
|
164
|
+
console.log(' Aguardando o celular…');
|
|
165
|
+
|
|
166
|
+
const ev = await c.receber(m => m.evento === 'pedido' || m.evento === 'fim');
|
|
167
|
+
if (ev.evento === 'fim') return falhar(ev.estado === 'expirado' ? 'o convite expirou sem pedido; gere outro.' : 'convite encerrado: ' + ev.estado);
|
|
168
|
+
|
|
169
|
+
// Outro aparelho tentando o mesmo convite: avisa enquanto a decisão estiver aberta.
|
|
170
|
+
let aberto = true;
|
|
171
|
+
(async () => {
|
|
172
|
+
while (aberto) {
|
|
173
|
+
try { await c.receber(m => m.evento === 'outroAparelho'); } catch (_) { return; }
|
|
174
|
+
if (!aberto) return;
|
|
175
|
+
console.log('');
|
|
176
|
+
console.log(' ⚠ OUTRO aparelho tentou usar este convite. Se o seu celular mostrar "Outro aparelho');
|
|
177
|
+
console.log(' usou este convite", NÃO digite o código: aperte Enter para recusar e gere outro QR.');
|
|
178
|
+
}
|
|
179
|
+
})();
|
|
180
|
+
|
|
181
|
+
console.log('');
|
|
182
|
+
console.log(' Pedido de pareamento do aparelho "' + ev.aparelho.nome + '".');
|
|
183
|
+
console.log(' No CELULAR aparece a impressão digital dele (XXXX-XXXX-XXXX-XXXX).');
|
|
184
|
+
console.log(' Digite aqui os 8 primeiros caracteres dela (sem hífen) para aceitar, ou só Enter para recusar.');
|
|
185
|
+
console.log(' Se o celular disser "Outro aparelho usou este convite", recuse.');
|
|
186
|
+
console.log(' Decida até ' + hora(conv.exp) + ' (depois disso o convite vence e nada é gravado).');
|
|
187
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
188
|
+
const fimAntes = c.receber(m => m.evento === 'fim').then(m => { rl.close(); return m; }, () => null);
|
|
189
|
+
try {
|
|
190
|
+
for (;;) {
|
|
191
|
+
const resposta = await Promise.race([perguntar(rl, ' Código (8 caracteres): '), fimAntes.then(() => null)]);
|
|
192
|
+
if (resposta === null) {
|
|
193
|
+
const m = await fimAntes;
|
|
194
|
+
return falhar('o convite terminou antes da confirmação (' + (m ? m.estado : 'conexão caiu') + ').');
|
|
195
|
+
}
|
|
196
|
+
const codigo = String(resposta).trim();
|
|
197
|
+
const pedido = codigo
|
|
198
|
+
? { op: 'decidir', pid: conv.pid, aceitar: true, confirmacao: codigo }
|
|
199
|
+
: { op: 'decidir', pid: conv.pid, aceitar: false };
|
|
200
|
+
const r = await c.pedir(pedido);
|
|
201
|
+
if (!r.ok && r.erro === 'E_CONFIRMACAO') {
|
|
202
|
+
console.log(' ✗ Não confere com a impressão digital do celular. Confira e digite de novo (ou Enter para recusar).');
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
exigirOk(r);
|
|
206
|
+
if (r.estado === 'aceito') console.log(' ✔ aparelho pareado: ' + ev.aparelho.nome);
|
|
207
|
+
else falhar(r.estado === 'recusado' ? 'pareamento recusado; nada foi gravado.' : 'pareamento não concluído (' + r.estado + ').');
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
} finally {
|
|
211
|
+
aberto = false;
|
|
212
|
+
rl.close();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function exportarTelas(umaVez) {
|
|
218
|
+
const X = require('./exportar-telas');
|
|
219
|
+
let cfg;
|
|
220
|
+
try { cfg = X.lerConfig(process.env); } catch (e) { return falhar(e.message, 2); }
|
|
221
|
+
if (umaVez) {
|
|
222
|
+
try {
|
|
223
|
+
const r = await X.exportarUmaVez(cfg);
|
|
224
|
+
console.log(' ✔ ' + r.exportados.length + ' terminal(is) exportado(s) para ' + cfg.dir);
|
|
225
|
+
} catch (e) { falhar('falha ao exportar (' + (e.codigo || e.code || 'erro') + ')'); }
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const sinal = { parar: false };
|
|
229
|
+
process.on('SIGINT', () => { sinal.parar = true; });
|
|
230
|
+
process.on('SIGTERM', () => { sinal.parar = true; });
|
|
231
|
+
logDaemon('exportando telas de ' + cfg.permitidas.length + ' entrada(s) permitida(s) para ' + cfg.dir + ' a cada ' + cfg.intervaloMs + ' ms');
|
|
232
|
+
await X.exportarTelas(Object.assign({ sinal, log: logDaemon }, cfg));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function main(args) {
|
|
236
|
+
const todos = args || [];
|
|
237
|
+
const lista = todos.filter(a => !['--json', '--mostrar-url', '--uma-vez'].includes(a));
|
|
238
|
+
const json = todos.includes('--json');
|
|
239
|
+
const sub = String(lista[0] || '').toLowerCase();
|
|
240
|
+
switch (sub) {
|
|
241
|
+
case 'iniciar': case 'start': case 'daemon': return iniciar();
|
|
242
|
+
case 'parear': case 'pair': return parear(todos.includes('--mostrar-url'));
|
|
243
|
+
case 'aparelhos': case 'devices': return aparelhos(json);
|
|
244
|
+
case 'revogar': case 'revoke': return revogar(lista[1]);
|
|
245
|
+
case 'status': return status(json);
|
|
246
|
+
case 'exportar-telas': return exportarTelas(todos.includes('--uma-vez'));
|
|
247
|
+
case '': case 'ajuda': case 'help': case '--help': case '-h':
|
|
248
|
+
console.log(AJUDA); return;
|
|
249
|
+
default:
|
|
250
|
+
console.error(AJUDA);
|
|
251
|
+
process.exitCode = 2;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = { main };
|
package/hub/controle.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Controle local do daemon: só o MESMO usuário do SO comanda.
|
|
3
|
+
//
|
|
4
|
+
// - Linux/macOS: socket unix <TS_HUB_DIR>/controle.sock com 0600, dentro do diretório 0700
|
|
5
|
+
// (o kernel exige permissão de escrita no arquivo do socket para conectar).
|
|
6
|
+
// - Windows: named pipe de nome aleatório (128 bits), anotado em controle.json.
|
|
7
|
+
// - Nos dois: controle.json (0600; no Windows, ACL do perfil do usuário) guarda também uma
|
|
8
|
+
// FICHA aleatória de 256 bits que o cliente precisa apresentar na primeira mensagem. Quem
|
|
9
|
+
// não lê o arquivo não comanda, mesmo que alcance o socket/pipe.
|
|
10
|
+
//
|
|
11
|
+
// Protocolo: uma mensagem JSON por linha. Primeira: { op:"ola", ficha }.
|
|
12
|
+
// Pedidos: status | aparelhos | revogar {id} | parear | decidir {pid, aceitar, confirmacao}.
|
|
13
|
+
// `confirmacao` = os 8 primeiros caracteres da impressão digital mostrada no celular,
|
|
14
|
+
// digitados no PC (E_CONFIRMACAO se não conferir; no terceiro erro o pedido é recusado).
|
|
15
|
+
// Respostas: { r:<op>, ok:true, ... } ou { r:<op>, ok:false, erro:"E_..." }; eventos de
|
|
16
|
+
// pareamento: { evento:"convite"|"pedido"|"outroAparelho"|"fim", ... }.
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const net = require('net');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const crypto = require('crypto');
|
|
22
|
+
const estado = require('./estado');
|
|
23
|
+
|
|
24
|
+
const LINHA_MAX = 64 * 1024;
|
|
25
|
+
const CONEXOES_MAX = 8;
|
|
26
|
+
const OLA_MS = 5000;
|
|
27
|
+
|
|
28
|
+
function arquivoControle(dir) { return path.join(dir, 'controle.json'); }
|
|
29
|
+
|
|
30
|
+
function iguaisSeguro(a, b) {
|
|
31
|
+
const x = Buffer.from(String(a)), y = Buffer.from(String(b));
|
|
32
|
+
return x.length === y.length && crypto.timingSafeEqual(x, y);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function leitorLinhas(sock, aoLinha) {
|
|
36
|
+
let buf = '';
|
|
37
|
+
sock.setEncoding('utf8');
|
|
38
|
+
sock.on('data', parte => {
|
|
39
|
+
buf += parte;
|
|
40
|
+
if (buf.length > LINHA_MAX && buf.indexOf('\n') < 0) { sock.destroy(); return; }
|
|
41
|
+
let i;
|
|
42
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
43
|
+
const linha = buf.slice(0, i);
|
|
44
|
+
buf = buf.slice(i + 1);
|
|
45
|
+
if (!linha.trim()) continue;
|
|
46
|
+
let msg;
|
|
47
|
+
try { msg = JSON.parse(linha); } catch (_) { sock.destroy(); return; }
|
|
48
|
+
if (!msg || typeof msg !== 'object' || Array.isArray(msg)) { sock.destroy(); return; }
|
|
49
|
+
aoLinha(msg);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function escrever(sock, obj) {
|
|
55
|
+
if (!sock.destroyed) { try { sock.write(JSON.stringify(obj) + '\n'); } catch (_) {} }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Tenta conectar a um endereço; resolve true se alguém atende. */
|
|
59
|
+
function alguemAtende(endereco) {
|
|
60
|
+
return new Promise(resolve => {
|
|
61
|
+
const s = net.connect(endereco);
|
|
62
|
+
const fim = r => { s.destroy(); resolve(r); };
|
|
63
|
+
s.once('connect', () => fim(true));
|
|
64
|
+
s.once('error', () => fim(false));
|
|
65
|
+
setTimeout(() => fim(false), 1500).unref();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Abre o servidor de controle do hub. Recusa se já há um daemon atendendo neste diretório.
|
|
71
|
+
* Devolve { endereco, fechar() }.
|
|
72
|
+
*/
|
|
73
|
+
async function abrirControle(hub, opcoes) {
|
|
74
|
+
const o = opcoes || {};
|
|
75
|
+
const dir = hub.dir;
|
|
76
|
+
const arqCtl = arquivoControle(dir);
|
|
77
|
+
const plataforma = o.plataforma || process.platform;
|
|
78
|
+
|
|
79
|
+
// Um daemon por diretório de estado.
|
|
80
|
+
try {
|
|
81
|
+
const anterior = estado.lerJson(arqCtl);
|
|
82
|
+
if (anterior && typeof anterior.endereco === 'string' && await alguemAtende(anterior.endereco)) {
|
|
83
|
+
throw new Error('já existe um hub rodando com o diretório ' + dir);
|
|
84
|
+
}
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (/já existe um hub/.test(e.message)) throw e;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ficha = crypto.randomBytes(32).toString('base64url');
|
|
90
|
+
const endereco = plataforma === 'win32'
|
|
91
|
+
? '\\\\.\\pipe\\ts-hub-' + crypto.randomBytes(16).toString('hex')
|
|
92
|
+
: path.join(dir, 'controle.sock');
|
|
93
|
+
if (plataforma !== 'win32') {
|
|
94
|
+
if (await alguemAtende(endereco)) throw new Error('já existe um hub rodando com o diretório ' + dir);
|
|
95
|
+
try { fs.unlinkSync(endereco); } catch (_) {}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const clientes = new Set();
|
|
99
|
+
const servidor = net.createServer(sock => atender(hub, sock, ficha, clientes));
|
|
100
|
+
servidor.maxConnections = CONEXOES_MAX;
|
|
101
|
+
|
|
102
|
+
await new Promise((resolve, reject) => {
|
|
103
|
+
servidor.once('error', reject);
|
|
104
|
+
// O socket nasce já com 0600 (umask), sem janela em que outro usuário o abra.
|
|
105
|
+
const antiga = plataforma === 'win32' ? null : process.umask(0o177);
|
|
106
|
+
try {
|
|
107
|
+
servidor.listen(endereco, () => { servidor.removeListener('error', reject); resolve(); });
|
|
108
|
+
} finally {
|
|
109
|
+
if (antiga !== null) process.umask(antiga);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
if (plataforma !== 'win32') fs.chmodSync(endereco, 0o600);
|
|
113
|
+
estado.gravarPrivado(arqCtl, JSON.stringify({ v: 1, endereco, ficha, pid: process.pid }) + '\n');
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
endereco,
|
|
117
|
+
fechar() {
|
|
118
|
+
return new Promise(resolve => {
|
|
119
|
+
for (const c of clientes) c.destroy();
|
|
120
|
+
servidor.close(() => resolve());
|
|
121
|
+
try {
|
|
122
|
+
const atual = estado.lerJson(arqCtl);
|
|
123
|
+
if (atual && atual.endereco === endereco) fs.unlinkSync(arqCtl);
|
|
124
|
+
} catch (_) {}
|
|
125
|
+
if (plataforma !== 'win32') { try { fs.unlinkSync(endereco); } catch (_) {} }
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function atender(hub, sock, ficha, clientes) {
|
|
132
|
+
clientes.add(sock);
|
|
133
|
+
let autenticado = false;
|
|
134
|
+
const meusConvites = new Set();
|
|
135
|
+
const limite = setTimeout(() => { if (!autenticado) sock.destroy(); }, OLA_MS);
|
|
136
|
+
sock.on('error', () => {});
|
|
137
|
+
sock.on('close', () => {
|
|
138
|
+
clearTimeout(limite);
|
|
139
|
+
clientes.delete(sock);
|
|
140
|
+
// Quem abriu o convite foi embora: recusa o pendente e fecha.
|
|
141
|
+
for (const pid of meusConvites) hub.cancelarConvite(pid).catch(() => {});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
let fila = Promise.resolve();
|
|
145
|
+
leitorLinhas(sock, msg => {
|
|
146
|
+
fila = fila.then(() => tratar(msg)).catch(() => {});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
async function tratar(msg) {
|
|
150
|
+
const op = typeof msg.op === 'string' ? msg.op : '';
|
|
151
|
+
if (!autenticado) {
|
|
152
|
+
if (op === 'ola' && iguaisSeguro(msg.ficha, ficha)) {
|
|
153
|
+
autenticado = true;
|
|
154
|
+
clearTimeout(limite);
|
|
155
|
+
escrever(sock, { r: 'ola', ok: true, id: hub.id });
|
|
156
|
+
} else {
|
|
157
|
+
hub.auditar({ tipo: 'controle', resultado: 'E_CONTROLE_NEGADO' });
|
|
158
|
+
escrever(sock, { r: 'ola', ok: false, erro: 'E_CONTROLE_NEGADO' });
|
|
159
|
+
sock.end();
|
|
160
|
+
setTimeout(() => sock.destroy(), 200).unref();
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
switch (op) {
|
|
166
|
+
case 'status': return escrever(sock, { r: op, ok: true, status: hub.status() });
|
|
167
|
+
case 'aparelhos': return escrever(sock, { r: op, ok: true, aparelhos: hub.listarAparelhos() });
|
|
168
|
+
case 'revogar': {
|
|
169
|
+
const revogado = await hub.revogar(String(msg.id || ''));
|
|
170
|
+
return escrever(sock, { r: op, ok: true, revogado });
|
|
171
|
+
}
|
|
172
|
+
case 'parear': {
|
|
173
|
+
const c = await hub.abrirConvite(ev => {
|
|
174
|
+
if (ev.evento === 'fim') meusConvites.delete(ev.pid);
|
|
175
|
+
escrever(sock, ev);
|
|
176
|
+
});
|
|
177
|
+
meusConvites.add(c.pid);
|
|
178
|
+
escrever(sock, { r: op, ok: true });
|
|
179
|
+
// A URL leva o segredo do convite: vai só para o terminal de quem pediu.
|
|
180
|
+
return escrever(sock, { evento: 'convite', pid: c.pid, url: c.url, exp: c.exp, conectado: c.conectado });
|
|
181
|
+
}
|
|
182
|
+
case 'decidir': {
|
|
183
|
+
if (!meusConvites.has(msg.pid)) return escrever(sock, { r: op, ok: false, erro: 'E_PAREAMENTO_ESTADO' });
|
|
184
|
+
// Aceitar exige o código digitado no PC (8 primeiros caracteres da impressão do celular).
|
|
185
|
+
const estadoFinal = await hub.decidir(msg.pid, msg.aceitar === true, typeof msg.confirmacao === 'string' ? msg.confirmacao.slice(0, 64) : undefined);
|
|
186
|
+
return escrever(sock, { r: op, ok: true, estado: estadoFinal });
|
|
187
|
+
}
|
|
188
|
+
default:
|
|
189
|
+
return escrever(sock, { r: op, ok: false, erro: 'E_METODO' });
|
|
190
|
+
}
|
|
191
|
+
} catch (e) {
|
|
192
|
+
const codigo = e && /^E_[A-Z_]{1,40}$/.test(String(e.codigo)) ? e.codigo : 'E_INTERNO';
|
|
193
|
+
return escrever(sock, { r: op, ok: false, erro: codigo });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Cliente do controle local. Devolve { enviar(obj), receber(filtro, ms), fechar(), id }.
|
|
200
|
+
* `ficha` pode ser forçada (testes de recusa).
|
|
201
|
+
*/
|
|
202
|
+
async function conectarControle(dir, opcoes) {
|
|
203
|
+
const o = opcoes || {};
|
|
204
|
+
let ctl;
|
|
205
|
+
try { ctl = estado.lerJson(arquivoControle(dir)); } catch (_) {
|
|
206
|
+
throw Object.assign(new Error('o hub não está rodando (inicie com: ts hub iniciar)'), { codigo: 'E_SEM_HUB' });
|
|
207
|
+
}
|
|
208
|
+
const sock = await new Promise((resolve, reject) => {
|
|
209
|
+
const s = net.connect(ctl.endereco);
|
|
210
|
+
s.once('connect', () => resolve(s));
|
|
211
|
+
s.once('error', () => reject(Object.assign(new Error('o hub não está rodando (inicie com: ts hub iniciar)'), { codigo: 'E_SEM_HUB' })));
|
|
212
|
+
});
|
|
213
|
+
const caixa = [];
|
|
214
|
+
const esperando = [];
|
|
215
|
+
let fechado = false;
|
|
216
|
+
const entregar = () => {
|
|
217
|
+
for (let i = 0; i < esperando.length; i++) {
|
|
218
|
+
const w = esperando[i];
|
|
219
|
+
const k = caixa.findIndex(w.filtro);
|
|
220
|
+
if (k >= 0) {
|
|
221
|
+
const [m] = caixa.splice(k, 1);
|
|
222
|
+
esperando.splice(i--, 1);
|
|
223
|
+
clearTimeout(w.timer);
|
|
224
|
+
w.resolve(m);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
leitorLinhas(sock, msg => { caixa.push(msg); entregar(); });
|
|
229
|
+
sock.on('error', () => {});
|
|
230
|
+
sock.on('close', () => {
|
|
231
|
+
fechado = true;
|
|
232
|
+
for (const w of esperando.splice(0)) { clearTimeout(w.timer); w.reject(Object.assign(new Error('conexão com o hub caiu'), { codigo: 'E_SEM_HUB' })); }
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const cliente = {
|
|
236
|
+
enviar(obj) { escrever(sock, obj); },
|
|
237
|
+
receber(filtro, ms) {
|
|
238
|
+
const f = typeof filtro === 'function' ? filtro : () => true;
|
|
239
|
+
return new Promise((resolve, reject) => {
|
|
240
|
+
const w = { filtro: f, resolve, reject, timer: null };
|
|
241
|
+
if (ms) w.timer = setTimeout(() => { esperando.splice(esperando.indexOf(w), 1); reject(Object.assign(new Error('tempo esgotado esperando o hub'), { codigo: 'E_TEMPO' })); }, ms);
|
|
242
|
+
esperando.push(w);
|
|
243
|
+
entregar();
|
|
244
|
+
if (fechado && esperando.includes(w)) { esperando.splice(esperando.indexOf(w), 1); clearTimeout(w.timer); reject(Object.assign(new Error('conexão com o hub caiu'), { codigo: 'E_SEM_HUB' })); }
|
|
245
|
+
});
|
|
246
|
+
},
|
|
247
|
+
async pedir(obj, ms) {
|
|
248
|
+
escrever(sock, obj);
|
|
249
|
+
return cliente.receber(m => m.r === obj.op, ms || 15000);
|
|
250
|
+
},
|
|
251
|
+
fechar() { sock.end(); sock.destroy(); },
|
|
252
|
+
};
|
|
253
|
+
const ola = await cliente.pedir({ op: 'ola', ficha: o.ficha !== undefined ? o.ficha : ctl.ficha }, 5000);
|
|
254
|
+
if (!ola.ok) {
|
|
255
|
+
cliente.fechar();
|
|
256
|
+
throw Object.assign(new Error('o hub recusou o controle (ficha inválida: outro usuário?)'), { codigo: ola.erro || 'E_CONTROLE_NEGADO' });
|
|
257
|
+
}
|
|
258
|
+
cliente.id = ola.id;
|
|
259
|
+
return cliente;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = { abrirControle, conectarControle, arquivoControle };
|
package/hub/dpapi.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// DPAPI do Windows (escopo do usuário atual) sem dependência nova: usa o PowerShell do
|
|
3
|
+
// próprio sistema, pelo caminho absoluto (não pelo PATH), e passa os bytes pela entrada
|
|
4
|
+
// padrão — nunca pela linha de comando, que outros processos conseguem ler.
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { execFile } = require('child_process');
|
|
8
|
+
|
|
9
|
+
// Entropia fixa (não é segredo): amarra o blob a este uso, para um blob DPAPI de outro
|
|
10
|
+
// programa do mesmo usuário não ser aceito aqui e vice-versa.
|
|
11
|
+
const ENTROPIA = 'ts-hub/1/chave-no';
|
|
12
|
+
|
|
13
|
+
const PREAMBULO =
|
|
14
|
+
"$ErrorActionPreference='Stop';" +
|
|
15
|
+
'Add-Type -AssemblyName System.Security;' +
|
|
16
|
+
"$e=[Text.Encoding]::UTF8.GetBytes('" + ENTROPIA + "');" +
|
|
17
|
+
'$i=[Convert]::FromBase64String([Console]::In.ReadToEnd().Trim());' +
|
|
18
|
+
'$s=[System.Security.Cryptography.DataProtectionScope]::CurrentUser;';
|
|
19
|
+
|
|
20
|
+
const SCRIPT_PROTEGER = PREAMBULO +
|
|
21
|
+
'$o=[System.Security.Cryptography.ProtectedData]::Protect($i,$e,$s);' +
|
|
22
|
+
'[Console]::Out.Write([Convert]::ToBase64String($o))';
|
|
23
|
+
const SCRIPT_ABRIR = PREAMBULO +
|
|
24
|
+
'$o=[System.Security.Cryptography.ProtectedData]::Unprotect($i,$e,$s);' +
|
|
25
|
+
'[Console]::Out.Write([Convert]::ToBase64String($o))';
|
|
26
|
+
|
|
27
|
+
function powershell() {
|
|
28
|
+
const raiz = process.env.SystemRoot || process.env.windir || 'C:\\Windows';
|
|
29
|
+
return path.join(raiz, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function rodar(script, bytes) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const filho = execFile(powershell(),
|
|
35
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
36
|
+
{ windowsHide: true, timeout: 60000, maxBuffer: 1 << 20 },
|
|
37
|
+
(err, stdout) => {
|
|
38
|
+
if (err) return reject(new Error('DPAPI indisponível ou falhou (código ' + (err.code || '?') + ')'));
|
|
39
|
+
const out = String(stdout || '').trim();
|
|
40
|
+
if (!/^[A-Za-z0-9+/=]+$/.test(out)) return reject(new Error('DPAPI devolveu saída inesperada'));
|
|
41
|
+
resolve(Buffer.from(out, 'base64'));
|
|
42
|
+
});
|
|
43
|
+
filho.stdin.on('error', () => {});
|
|
44
|
+
filho.stdin.end(Buffer.from(bytes).toString('base64'));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Protetor padrão no Windows: { nome, proteger(bytes) → Buffer, abrir(bytes) → Buffer }. */
|
|
49
|
+
const dpapi = Object.freeze({
|
|
50
|
+
nome: 'dpapi',
|
|
51
|
+
proteger: bytes => rodar(SCRIPT_PROTEGER, bytes),
|
|
52
|
+
abrir: bytes => rodar(SCRIPT_ABRIR, bytes),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
module.exports = { dpapi };
|