dbreportes-conector 1.0.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 +124 -0
- package/bin/conector.js +195 -0
- package/package.json +32 -0
- package/src/conector.js +277 -0
- package/src/protocolo.js +67 -0
package/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Conector de DBReportes
|
|
2
|
+
|
|
3
|
+
Enlace local que permite a DBReportes consultar una base de datos privada
|
|
4
|
+
—`localhost`, un contenedor, la red interna de la empresa— **sin exponerla a
|
|
5
|
+
internet y sin abrir ningún puerto**.
|
|
6
|
+
|
|
7
|
+
## Cómo funciona
|
|
8
|
+
|
|
9
|
+
El conector abre una conexión **saliente** hacia el servidor de DBReportes
|
|
10
|
+
(WebSocket sobre 443) y queda a la espera. Cuando alguien ejecuta un reporte,
|
|
11
|
+
el servidor pide por ese enlace que se abra un socket contra la base, y los
|
|
12
|
+
bytes viajan multiplexados por la misma conexión.
|
|
13
|
+
|
|
14
|
+
Hacia fuera se comporta como cualquier programa que habla con un servicio web:
|
|
15
|
+
no escucha en ningún puerto, no publica la base y no necesita que se toque el
|
|
16
|
+
cortafuegos.
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
tu red DBReportes
|
|
20
|
+
┌──────────────┐ ┌──────────────┐
|
|
21
|
+
│ base │◀── socket local ──┐ │ │
|
|
22
|
+
│ conector │───── WSS 443 ─────┼─────▶│ servidor │
|
|
23
|
+
└──────────────┘ (saliente) └ └──────────────┘
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Uso
|
|
27
|
+
|
|
28
|
+
El token se genera desde la página, en **Conectores → Nuevo conector**. Se
|
|
29
|
+
muestra **una sola vez**: no queda guardado en claro y no hay forma de volver a
|
|
30
|
+
verlo.
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
npx dbreportes-conector --servidor https://reportes.tuempresa.com --token dbrk_...
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Con variables de entorno, que es lo cómodo en una máquina virtual o un
|
|
37
|
+
contenedor:
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
DBREPORTES_SERVIDOR=https://reportes.tuempresa.com \
|
|
41
|
+
DBREPORTES_TOKEN=dbrk_... \
|
|
42
|
+
npx dbreportes-conector
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Mientras el conector esté en marcha, en el formulario de conexión de
|
|
46
|
+
DBReportes se elige este conector y se escribe el host **tal como lo ve esta
|
|
47
|
+
máquina**: `localhost`, el nombre de un contenedor o una dirección de la red
|
|
48
|
+
interna.
|
|
49
|
+
|
|
50
|
+
El enlace es de la empresa, no de quien lo levantó: se enciende una vez y
|
|
51
|
+
todos sus empleados trabajan contra esa base desde la página.
|
|
52
|
+
|
|
53
|
+
## Opciones
|
|
54
|
+
|
|
55
|
+
| Opción | Variable | Qué hace |
|
|
56
|
+
|---|---|---|
|
|
57
|
+
| `--servidor <url>` | `DBREPORTES_SERVIDOR` | Dirección de DBReportes |
|
|
58
|
+
| `--token <token>` | `DBREPORTES_TOKEN` | Token emitido al dar de alta el conector |
|
|
59
|
+
| `--ca <ruta>` | `DBREPORTES_CA` | Certificado de una autoridad propia |
|
|
60
|
+
| `--sin-verificar` | `DBREPORTES_VERIFICAR_TLS=off` | No verificar el certificado de la base |
|
|
61
|
+
| `--json` | `DBREPORTES_JSON=on` | Una línea de JSON por suceso |
|
|
62
|
+
| `--version` | | Mostrar la versión |
|
|
63
|
+
| `--help` | | Mostrar la ayuda |
|
|
64
|
+
|
|
65
|
+
Códigos de salida: `0` cierre ordenado · `2` faltan datos o son inválidos ·
|
|
66
|
+
`3` no se pudo leer el certificado.
|
|
67
|
+
|
|
68
|
+
## Cifrado
|
|
69
|
+
|
|
70
|
+
Dos capas, y ninguna la puede leer el servidor:
|
|
71
|
+
|
|
72
|
+
1. **WSS** cifra el tramo entre el conector y DBReportes.
|
|
73
|
+
2. Si la conexión usa `require`, `verify-ca` o `verify-full`, **el cifrado
|
|
74
|
+
contra la base lo termina el conector**, con el nombre real del host. El
|
|
75
|
+
servidor solo reenvía bytes ya cifrados.
|
|
76
|
+
|
|
77
|
+
Por eso `--sin-verificar` es una mala idea salvo en una red de confianza con un
|
|
78
|
+
certificado autofirmado. Lo correcto en ese caso es pasar la autoridad con
|
|
79
|
+
`--ca`.
|
|
80
|
+
|
|
81
|
+
La conexión a la base es **solo de lectura**: el servidor pone la sesión en
|
|
82
|
+
`default_transaction_read_only` y rechaza cualquier escritura.
|
|
83
|
+
|
|
84
|
+
## Para automatizar
|
|
85
|
+
|
|
86
|
+
No hay ningún paso interactivo: nunca pregunta nada ni espera una tecla. Con
|
|
87
|
+
`--json` cada suceso sale como una línea de JSON, con `hora`, `nivel`, `evento`
|
|
88
|
+
y `mensaje`, lista para que otro programa la lea:
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
{"hora":"2026-09-01T20:14:03.221Z","nivel":"info","mensaje":"enlazado con wss://…","evento":"enlazado"}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Eventos: `arranque`, `enlazado`, `listo`, `stream`, `desenlazado`, `reintento`,
|
|
95
|
+
`aviso`, `cierre`.
|
|
96
|
+
|
|
97
|
+
`SIGINT` y `SIGTERM` cierran el enlace de forma ordenada, así que funciona
|
|
98
|
+
igual como servicio de systemd o como contenedor.
|
|
99
|
+
|
|
100
|
+
Ejemplo de unidad de systemd:
|
|
101
|
+
|
|
102
|
+
```ini
|
|
103
|
+
[Unit]
|
|
104
|
+
Description=Conector de DBReportes
|
|
105
|
+
After=network-online.target
|
|
106
|
+
|
|
107
|
+
[Service]
|
|
108
|
+
Environment=DBREPORTES_SERVIDOR=https://reportes.tuempresa.com
|
|
109
|
+
Environment=DBREPORTES_TOKEN=dbrk_...
|
|
110
|
+
Environment=DBREPORTES_JSON=on
|
|
111
|
+
ExecStart=/usr/bin/npx dbreportes-conector
|
|
112
|
+
Restart=always
|
|
113
|
+
RestartSec=5
|
|
114
|
+
|
|
115
|
+
[Install]
|
|
116
|
+
WantedBy=multi-user.target
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Si el enlace se cae, el conector reconecta solo con espera creciente (de 1 s
|
|
120
|
+
hasta 30 s), así que no hace falta vigilarlo.
|
|
121
|
+
|
|
122
|
+
## Requisitos
|
|
123
|
+
|
|
124
|
+
Node.js 20 o superior. Nada más.
|
package/bin/conector.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Arranque del conector desde la terminal.
|
|
3
|
+
//
|
|
4
|
+
// Pensado para que dé igual quién lo lance: una persona, el arranque de una
|
|
5
|
+
// máquina virtual o un agente automático. Por eso no pregunta nada, todo se
|
|
6
|
+
// puede dar por variables de entorno, y con --json cada suceso sale como una
|
|
7
|
+
// línea de JSON que otro programa puede leer.
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import process from 'node:process';
|
|
10
|
+
|
|
11
|
+
import { Conector, VERSION } from '../src/conector.js';
|
|
12
|
+
import { tokenBienFormado } from '../src/protocolo.js';
|
|
13
|
+
|
|
14
|
+
const USO = `dbreportes-conector ${VERSION}
|
|
15
|
+
|
|
16
|
+
Enlaza una base de datos privada con DBReportes sin exponerla a internet.
|
|
17
|
+
La conexión la abre esta máquina hacia el servidor; no se abre ningún puerto.
|
|
18
|
+
|
|
19
|
+
Uso:
|
|
20
|
+
npx dbreportes-conector --servidor <url> --token <token>
|
|
21
|
+
|
|
22
|
+
Opciones:
|
|
23
|
+
--servidor <url> Dirección de DBReportes (p. ej. https://reportes.empresa.com)
|
|
24
|
+
--token <token> Token del conector, emitido al darlo de alta
|
|
25
|
+
--ca <ruta> Certificado de la autoridad propia de tu base
|
|
26
|
+
--sin-verificar No verificar el certificado de la base (solo en red de confianza)
|
|
27
|
+
--json Una línea de JSON por suceso, para consumo automático
|
|
28
|
+
--version Mostrar la versión
|
|
29
|
+
--help Mostrar esta ayuda
|
|
30
|
+
|
|
31
|
+
Variables de entorno equivalentes:
|
|
32
|
+
DBREPORTES_SERVIDOR, DBREPORTES_TOKEN, DBREPORTES_CA,
|
|
33
|
+
DBREPORTES_VERIFICAR_TLS=off, DBREPORTES_JSON=on
|
|
34
|
+
|
|
35
|
+
Códigos de salida:
|
|
36
|
+
0 cierre ordenado
|
|
37
|
+
2 faltan datos o son inválidos
|
|
38
|
+
3 no se pudo leer el certificado indicado
|
|
39
|
+
|
|
40
|
+
Una vez enlazado, en el formulario de conexión de DBReportes se elige este
|
|
41
|
+
conector y se escribe el host tal como lo ve ESTA máquina (por ejemplo
|
|
42
|
+
"localhost" o el nombre de un contenedor).
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
function leerArgumentos(argv) {
|
|
46
|
+
const args = { flags: new Set() };
|
|
47
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
48
|
+
const a = argv[i];
|
|
49
|
+
switch (a) {
|
|
50
|
+
case '--servidor':
|
|
51
|
+
case '--server':
|
|
52
|
+
args.servidor = argv[++i];
|
|
53
|
+
break;
|
|
54
|
+
case '--token':
|
|
55
|
+
args.token = argv[++i];
|
|
56
|
+
break;
|
|
57
|
+
case '--ca':
|
|
58
|
+
args.ca = argv[++i];
|
|
59
|
+
break;
|
|
60
|
+
case '--sin-verificar':
|
|
61
|
+
args.flags.add('sin-verificar');
|
|
62
|
+
break;
|
|
63
|
+
case '--json':
|
|
64
|
+
args.flags.add('json');
|
|
65
|
+
break;
|
|
66
|
+
case '--version':
|
|
67
|
+
case '-v':
|
|
68
|
+
args.flags.add('version');
|
|
69
|
+
break;
|
|
70
|
+
case '--help':
|
|
71
|
+
case '-h':
|
|
72
|
+
args.flags.add('help');
|
|
73
|
+
break;
|
|
74
|
+
default:
|
|
75
|
+
if (a.startsWith('-')) args.desconocida = a;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return args;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const args = leerArgumentos(process.argv.slice(2));
|
|
83
|
+
|
|
84
|
+
if (args.flags.has('version')) {
|
|
85
|
+
process.stdout.write(VERSION + '\n');
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
if (args.flags.has('help')) {
|
|
89
|
+
process.stdout.write(USO);
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const enJSON = args.flags.has('json') || process.env.DBREPORTES_JSON === 'on';
|
|
94
|
+
|
|
95
|
+
/** Un suceso por línea: legible para una persona, analizable para un programa. */
|
|
96
|
+
function informar(nivel, mensaje, extra = {}) {
|
|
97
|
+
if (enJSON) {
|
|
98
|
+
const linea = { hora: new Date().toISOString(), nivel, mensaje, ...extra };
|
|
99
|
+
process.stdout.write(JSON.stringify(linea) + '\n');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const marca = nivel === 'error' ? '✗' : nivel === 'aviso' ? '!' : '·';
|
|
103
|
+
process.stdout.write(`${marca} ${mensaje}\n`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function salirCon(codigo, mensaje) {
|
|
107
|
+
informar('error', mensaje);
|
|
108
|
+
if (!enJSON) process.stderr.write('\nEjecuta con --help para ver el uso.\n');
|
|
109
|
+
process.exit(codigo);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (args.desconocida) salirCon(2, `opción desconocida: ${args.desconocida}`);
|
|
113
|
+
|
|
114
|
+
const servidor = args.servidor ?? process.env.DBREPORTES_SERVIDOR;
|
|
115
|
+
const token = args.token ?? process.env.DBREPORTES_TOKEN;
|
|
116
|
+
|
|
117
|
+
if (!servidor) salirCon(2, 'falta --servidor (o la variable DBREPORTES_SERVIDOR)');
|
|
118
|
+
if (!token) salirCon(2, 'falta --token (o la variable DBREPORTES_TOKEN)');
|
|
119
|
+
if (!tokenBienFormado(token)) {
|
|
120
|
+
salirCon(2, 'el token no tiene el formato esperado: debe empezar por "dbrk_"');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const rutaCA = args.ca ?? process.env.DBREPORTES_CA;
|
|
124
|
+
let caTLS = null;
|
|
125
|
+
if (rutaCA) {
|
|
126
|
+
try {
|
|
127
|
+
caTLS = fs.readFileSync(rutaCA);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
salirCon(3, `no se pudo leer el certificado ${rutaCA}: ${err.message}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const verificarTLS =
|
|
134
|
+
!args.flags.has('sin-verificar') && process.env.DBREPORTES_VERIFICAR_TLS !== 'off';
|
|
135
|
+
|
|
136
|
+
if (!verificarTLS) {
|
|
137
|
+
informar(
|
|
138
|
+
'aviso',
|
|
139
|
+
'la verificación del certificado de la base está desactivada: úsalo solo en una red de confianza',
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const conector = new Conector({ servidor, token, verificarTLS, caTLS });
|
|
144
|
+
|
|
145
|
+
conector.on('enlazado', ({ servidor: s }) => {
|
|
146
|
+
informar('info', `enlazado con ${s}`, { evento: 'enlazado', servidor: s });
|
|
147
|
+
informar(
|
|
148
|
+
'info',
|
|
149
|
+
'listo: elige este conector en el formulario de conexión y usa el host tal como lo ve esta máquina',
|
|
150
|
+
{ evento: 'listo' },
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
conector.on('desenlazado', ({ codigo, motivo }) => {
|
|
155
|
+
informar('aviso', `enlace cerrado (${codigo}${motivo ? ': ' + motivo : ''})`, {
|
|
156
|
+
evento: 'desenlazado',
|
|
157
|
+
codigo,
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
conector.on('reintento', ({ intento, esperaMs }) => {
|
|
162
|
+
informar('info', `reintentando en ${Math.round(esperaMs / 1000)} s (intento ${intento})`, {
|
|
163
|
+
evento: 'reintento',
|
|
164
|
+
intento,
|
|
165
|
+
esperaMs,
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
conector.on('stream', ({ host, puerto, tls }) => {
|
|
170
|
+
informar('info', `consulta hacia ${host}:${puerto}${tls ? ' (cifrada)' : ''}`, {
|
|
171
|
+
evento: 'stream',
|
|
172
|
+
host,
|
|
173
|
+
puerto,
|
|
174
|
+
tls,
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
conector.on('aviso', (mensaje) => informar('aviso', mensaje, { evento: 'aviso' }));
|
|
179
|
+
|
|
180
|
+
// Una señal cierra el enlace de forma ordenada: en una máquina virtual esto es
|
|
181
|
+
// lo que ocurre al apagar el servicio, y dejar sockets colgando alargaría el
|
|
182
|
+
// apagado sin motivo.
|
|
183
|
+
for (const senal of ['SIGINT', 'SIGTERM']) {
|
|
184
|
+
process.on(senal, () => {
|
|
185
|
+
informar('info', 'cerrando el enlace', { evento: 'cierre' });
|
|
186
|
+
conector.detener();
|
|
187
|
+
process.exit(0);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
informar('info', `conector ${VERSION}: abriendo enlace con ${servidor}`, {
|
|
192
|
+
evento: 'arranque',
|
|
193
|
+
version: VERSION,
|
|
194
|
+
});
|
|
195
|
+
conector.iniciar();
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dbreportes-conector",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Enlace local que permite a DBReportes alcanzar una base de datos privada (localhost, Docker, LAN) sin exponerla a internet.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Jonathan Paredes",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"dbreportes-conector": "./bin/conector.js"
|
|
10
|
+
},
|
|
11
|
+
"main": "./src/conector.js",
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"src",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"ws": "^8.18.0"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"dbreportes",
|
|
28
|
+
"postgres",
|
|
29
|
+
"tunnel",
|
|
30
|
+
"conector"
|
|
31
|
+
]
|
|
32
|
+
}
|
package/src/conector.js
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// El conector: abre el enlace hacia DBReportes y, cuando el servidor lo pide,
|
|
2
|
+
// conecta con la base que solo esta máquina alcanza.
|
|
3
|
+
//
|
|
4
|
+
// La conexión la abre siempre este lado. Nunca se escucha en ningún puerto ni
|
|
5
|
+
// se expone la base: hacia fuera se comporta como cualquier programa que habla
|
|
6
|
+
// con un servicio web.
|
|
7
|
+
import net from 'node:net';
|
|
8
|
+
import tls from 'node:tls';
|
|
9
|
+
import { EventEmitter } from 'node:events';
|
|
10
|
+
import { WebSocket } from 'ws';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
CABECERA_TOKEN,
|
|
14
|
+
RUTA_ENLACE,
|
|
15
|
+
TRAMA,
|
|
16
|
+
codificar,
|
|
17
|
+
codificarJSON,
|
|
18
|
+
decodificar,
|
|
19
|
+
decodificarJSON,
|
|
20
|
+
} from './protocolo.js';
|
|
21
|
+
|
|
22
|
+
/** Versión que se anuncia en el saludo. */
|
|
23
|
+
export const VERSION = '1.0.0';
|
|
24
|
+
|
|
25
|
+
/** Espera entre reintentos: arranca corta y crece hasta el tope. */
|
|
26
|
+
const ESPERA_MINIMA_MS = 1_000;
|
|
27
|
+
const ESPERA_MAXIMA_MS = 30_000;
|
|
28
|
+
|
|
29
|
+
/** Un socket que no llega a abrirse en este plazo se da por perdido. */
|
|
30
|
+
const ESPERA_DE_CONEXION_MS = 15_000;
|
|
31
|
+
|
|
32
|
+
/** Tope de la trama aceptada, igual que el del servidor. */
|
|
33
|
+
const TOPE_TRAMA = 8 * 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Conector enlazado a un servidor de DBReportes.
|
|
37
|
+
*
|
|
38
|
+
* Emite: 'enlazado', 'desenlazado', 'reintento', 'stream', 'aviso', 'error'.
|
|
39
|
+
*/
|
|
40
|
+
export class Conector extends EventEmitter {
|
|
41
|
+
#servidor;
|
|
42
|
+
#token;
|
|
43
|
+
#opciones;
|
|
44
|
+
#ws = null;
|
|
45
|
+
#streams = new Map();
|
|
46
|
+
#intento = 0;
|
|
47
|
+
#reintento = null;
|
|
48
|
+
#detenido = false;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @param {object} cfg
|
|
52
|
+
* @param {string} cfg.servidor URL del servidor (wss://… o https://…).
|
|
53
|
+
* @param {string} cfg.token Token emitido al dar de alta el conector.
|
|
54
|
+
* @param {boolean} [cfg.verificarTLS=true] Verificar el certificado de la base.
|
|
55
|
+
* @param {string} [cfg.caTLS] Certificado de una autoridad propia.
|
|
56
|
+
*/
|
|
57
|
+
constructor({ servidor, token, verificarTLS = true, caTLS = null }) {
|
|
58
|
+
super();
|
|
59
|
+
this.#servidor = normalizarServidor(servidor);
|
|
60
|
+
this.#token = token;
|
|
61
|
+
this.#opciones = { verificarTLS, caTLS };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Abre el enlace y lo mantiene, reconectando si se cae. */
|
|
65
|
+
iniciar() {
|
|
66
|
+
this.#detenido = false;
|
|
67
|
+
this.#abrir();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Cierra el enlace y deja de reintentar. */
|
|
71
|
+
detener() {
|
|
72
|
+
this.#detenido = true;
|
|
73
|
+
if (this.#reintento) {
|
|
74
|
+
clearTimeout(this.#reintento);
|
|
75
|
+
this.#reintento = null;
|
|
76
|
+
}
|
|
77
|
+
this.#cerrarTodo();
|
|
78
|
+
if (this.#ws) {
|
|
79
|
+
try {
|
|
80
|
+
this.#ws.close(1000, 'cierre solicitado');
|
|
81
|
+
} catch {
|
|
82
|
+
/* ya cerrado */
|
|
83
|
+
}
|
|
84
|
+
this.#ws = null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Cuántos streams hay abiertos ahora mismo. */
|
|
89
|
+
get streamsAbiertos() {
|
|
90
|
+
return this.#streams.size;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Si el enlace está en pie. */
|
|
94
|
+
get enlazado() {
|
|
95
|
+
return this.#ws?.readyState === WebSocket.OPEN;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#abrir() {
|
|
99
|
+
const url = this.#servidor + RUTA_ENLACE;
|
|
100
|
+
const ws = new WebSocket(url, {
|
|
101
|
+
headers: { [CABECERA_TOKEN]: this.#token },
|
|
102
|
+
maxPayload: TOPE_TRAMA,
|
|
103
|
+
// Sin Origin: lo pone un navegador, y el servidor rechaza los que no
|
|
104
|
+
// reconoce. Un conector no es una página.
|
|
105
|
+
followRedirects: false,
|
|
106
|
+
});
|
|
107
|
+
this.#ws = ws;
|
|
108
|
+
|
|
109
|
+
ws.on('open', () => {
|
|
110
|
+
this.#intento = 0;
|
|
111
|
+
ws.send(
|
|
112
|
+
codificar(TRAMA.SALUDO, 0, codificarJSON({ version: VERSION, motores: ['postgres'] })),
|
|
113
|
+
);
|
|
114
|
+
this.emit('enlazado', { servidor: this.#servidor });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
ws.on('message', (datos) => this.#recibir(datos));
|
|
118
|
+
|
|
119
|
+
ws.on('close', (codigo, motivo) => {
|
|
120
|
+
this.#cerrarTodo();
|
|
121
|
+
this.#ws = null;
|
|
122
|
+
this.emit('desenlazado', { codigo, motivo: motivo?.toString() ?? '' });
|
|
123
|
+
this.#programarReintento();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
ws.on('error', (err) => {
|
|
127
|
+
// El cierre viene detrás y es quien programa el reintento; aquí solo se
|
|
128
|
+
// informa, para que un fallo de red no acabe en excepción sin capturar.
|
|
129
|
+
this.emit('aviso', err.message);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Un enlace mudo se cierra: el servidor manda ping cada 25 s.
|
|
133
|
+
ws.on('ping', () => {
|
|
134
|
+
/* la librería responde el pong sola */
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#programarReintento() {
|
|
139
|
+
if (this.#detenido) return;
|
|
140
|
+
this.#intento += 1;
|
|
141
|
+
const espera = Math.min(ESPERA_MINIMA_MS * 2 ** (this.#intento - 1), ESPERA_MAXIMA_MS);
|
|
142
|
+
this.emit('reintento', { intento: this.#intento, esperaMs: espera });
|
|
143
|
+
this.#reintento = setTimeout(() => this.#abrir(), espera);
|
|
144
|
+
this.#reintento.unref?.();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#enviar(tipo, stream, cuerpo) {
|
|
148
|
+
if (this.#ws?.readyState !== WebSocket.OPEN) return;
|
|
149
|
+
this.#ws.send(codificar(tipo, stream, cuerpo));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#recibir(datos) {
|
|
153
|
+
const trama = decodificar(datos);
|
|
154
|
+
if (!trama) return;
|
|
155
|
+
|
|
156
|
+
switch (trama.tipo) {
|
|
157
|
+
case TRAMA.ABRIR:
|
|
158
|
+
this.#abrirStream(trama.stream, decodificarJSON(trama.cuerpo));
|
|
159
|
+
break;
|
|
160
|
+
|
|
161
|
+
case TRAMA.DATOS: {
|
|
162
|
+
const socket = this.#streams.get(trama.stream);
|
|
163
|
+
// El cuerpo apunta al búfer del mensaje: se copia porque la escritura
|
|
164
|
+
// puede quedar en cola.
|
|
165
|
+
if (socket) socket.write(Buffer.from(trama.cuerpo));
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
case TRAMA.CERRAR: {
|
|
170
|
+
const socket = this.#streams.get(trama.stream);
|
|
171
|
+
this.#streams.delete(trama.stream);
|
|
172
|
+
socket?.end();
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
default:
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#abrirStream(id, destino) {
|
|
182
|
+
if (!destino || typeof destino.host !== 'string' || !Number.isInteger(destino.puerto)) {
|
|
183
|
+
this.#enviar(TRAMA.FALLO_AL_ABRIR, id, Buffer.from('destino inválido', 'utf8'));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const { host, puerto, tls: conTLS } = destino;
|
|
188
|
+
let socket;
|
|
189
|
+
|
|
190
|
+
if (conTLS) {
|
|
191
|
+
// El cifrado contra la base lo termina este extremo, con el nombre real
|
|
192
|
+
// del host: así el servidor solo ve bytes cifrados que no puede leer, y
|
|
193
|
+
// el certificado se valida contra quien de verdad responde.
|
|
194
|
+
socket = tls.connect({
|
|
195
|
+
host,
|
|
196
|
+
port: puerto,
|
|
197
|
+
servername: host,
|
|
198
|
+
rejectUnauthorized: this.#opciones.verificarTLS,
|
|
199
|
+
ca: this.#opciones.caTLS ?? undefined,
|
|
200
|
+
});
|
|
201
|
+
} else {
|
|
202
|
+
socket = net.connect({ host, port: puerto });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
socket.setTimeout(ESPERA_DE_CONEXION_MS);
|
|
206
|
+
let abierto = false;
|
|
207
|
+
|
|
208
|
+
const listoPara = conTLS ? 'secureConnect' : 'connect';
|
|
209
|
+
socket.once(listoPara, () => {
|
|
210
|
+
abierto = true;
|
|
211
|
+
socket.setTimeout(0);
|
|
212
|
+
socket.setNoDelay(true);
|
|
213
|
+
this.#streams.set(id, socket);
|
|
214
|
+
this.#enviar(TRAMA.ABIERTO, id, null);
|
|
215
|
+
this.emit('stream', { id, host, puerto, tls: Boolean(conTLS) });
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
socket.on('data', (trozo) => this.#enviar(TRAMA.DATOS, id, trozo));
|
|
219
|
+
|
|
220
|
+
socket.on('timeout', () => {
|
|
221
|
+
if (!abierto) socket.destroy(new Error('la base no respondió a tiempo'));
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
socket.on('error', (err) => {
|
|
225
|
+
if (!abierto) {
|
|
226
|
+
// El motivo viaja de vuelta para que en la página se vea por qué
|
|
227
|
+
// falló, en vez de un «no se pudo conectar» sin explicación.
|
|
228
|
+
this.#enviar(TRAMA.FALLO_AL_ABRIR, id, Buffer.from(motivoLegible(err), 'utf8'));
|
|
229
|
+
}
|
|
230
|
+
this.#streams.delete(id);
|
|
231
|
+
socket.destroy();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
socket.on('close', () => {
|
|
235
|
+
if (this.#streams.delete(id)) this.#enviar(TRAMA.CERRAR, id, null);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#cerrarTodo() {
|
|
240
|
+
for (const socket of this.#streams.values()) socket.destroy();
|
|
241
|
+
this.#streams.clear();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Deja la URL del servidor lista para abrir el WebSocket. */
|
|
246
|
+
export function normalizarServidor(servidor) {
|
|
247
|
+
if (typeof servidor !== 'string' || servidor.trim() === '') {
|
|
248
|
+
throw new Error('falta la dirección del servidor');
|
|
249
|
+
}
|
|
250
|
+
let url = servidor.trim().replace(/\/+$/, '');
|
|
251
|
+
if (url.startsWith('https://')) url = 'wss://' + url.slice('https://'.length);
|
|
252
|
+
else if (url.startsWith('http://')) url = 'ws://' + url.slice('http://'.length);
|
|
253
|
+
else if (!url.startsWith('ws://') && !url.startsWith('wss://')) url = 'wss://' + url;
|
|
254
|
+
return url;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Traduce el error del sistema a algo que se entienda en la página. */
|
|
258
|
+
export function motivoLegible(err) {
|
|
259
|
+
switch (err?.code) {
|
|
260
|
+
case 'ECONNREFUSED':
|
|
261
|
+
return 'la base rechazó la conexión: comprueba el puerto y que esté encendida';
|
|
262
|
+
case 'ENOTFOUND':
|
|
263
|
+
return 'no se encontró ese host desde la máquina del conector';
|
|
264
|
+
case 'ETIMEDOUT':
|
|
265
|
+
return 'la base no respondió a tiempo';
|
|
266
|
+
case 'EHOSTUNREACH':
|
|
267
|
+
case 'ENETUNREACH':
|
|
268
|
+
return 'esa dirección no es alcanzable desde la máquina del conector';
|
|
269
|
+
case 'CERT_HAS_EXPIRED':
|
|
270
|
+
return 'el certificado de la base está caducado';
|
|
271
|
+
case 'DEPTH_ZERO_SELF_SIGNED_CERT':
|
|
272
|
+
case 'SELF_SIGNED_CERT_IN_CHAIN':
|
|
273
|
+
return 'el certificado de la base es autofirmado: indica su autoridad con --ca';
|
|
274
|
+
default:
|
|
275
|
+
return err?.message ?? 'no se pudo conectar con la base';
|
|
276
|
+
}
|
|
277
|
+
}
|
package/src/protocolo.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Protocolo del enlace. Espeja internal/domain/enlace.go: si cambia uno, cambia
|
|
2
|
+
// el otro, porque los dos extremos leen los mismos bytes.
|
|
3
|
+
|
|
4
|
+
/** Ruta donde el conector abre su enlace saliente. */
|
|
5
|
+
export const RUTA_ENLACE = '/api/connector/link';
|
|
6
|
+
|
|
7
|
+
/** El token viaja en cabecera; en la URL quedaría en los registros de proxies. */
|
|
8
|
+
export const CABECERA_TOKEN = 'x-dbreportes-conector';
|
|
9
|
+
|
|
10
|
+
/** Prefijo de los tokens, para reconocerlos de un vistazo. */
|
|
11
|
+
export const PREFIJO_TOKEN = 'dbrk_';
|
|
12
|
+
|
|
13
|
+
/** Tipos de trama. */
|
|
14
|
+
export const TRAMA = Object.freeze({
|
|
15
|
+
ABRIR: 1,
|
|
16
|
+
ABIERTO: 2,
|
|
17
|
+
FALLO_AL_ABRIR: 3,
|
|
18
|
+
DATOS: 4,
|
|
19
|
+
CERRAR: 5,
|
|
20
|
+
SALUDO: 6,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** Un byte de tipo y cuatro de identificador de stream. */
|
|
24
|
+
const CABECERA = 5;
|
|
25
|
+
|
|
26
|
+
/** Arma el bloque binario listo para enviar. */
|
|
27
|
+
export function codificar(tipo, stream, cuerpo) {
|
|
28
|
+
const datos = cuerpo ?? Buffer.alloc(0);
|
|
29
|
+
const salida = Buffer.allocUnsafe(CABECERA + datos.length);
|
|
30
|
+
salida.writeUInt8(tipo, 0);
|
|
31
|
+
salida.writeUInt32BE(stream >>> 0, 1);
|
|
32
|
+
if (datos.length > 0) datos.copy(salida, CABECERA);
|
|
33
|
+
return salida;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Separa un bloque recibido; null si no llega a trama. */
|
|
37
|
+
export function decodificar(datos) {
|
|
38
|
+
const buf = Buffer.isBuffer(datos) ? datos : Buffer.from(datos);
|
|
39
|
+
if (buf.length < CABECERA) return null;
|
|
40
|
+
return {
|
|
41
|
+
tipo: buf.readUInt8(0),
|
|
42
|
+
stream: buf.readUInt32BE(1),
|
|
43
|
+
cuerpo: buf.subarray(CABECERA),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Serializa un cuerpo como JSON en UTF-8. */
|
|
48
|
+
export function codificarJSON(valor) {
|
|
49
|
+
return Buffer.from(JSON.stringify(valor), 'utf8');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Lee un cuerpo JSON; null si viene malformado. */
|
|
53
|
+
export function decodificarJSON(cuerpo) {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(Buffer.from(cuerpo).toString('utf8'));
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Comprueba que un token tenga la forma que emite el servidor. */
|
|
62
|
+
export function tokenBienFormado(token) {
|
|
63
|
+
if (typeof token !== 'string' || !token.startsWith(PREFIJO_TOKEN)) return false;
|
|
64
|
+
const cuerpo = token.slice(PREFIJO_TOKEN.length);
|
|
65
|
+
if (!/^[A-Za-z0-9_-]+$/.test(cuerpo)) return false;
|
|
66
|
+
return Buffer.from(cuerpo, 'base64url').length === 32;
|
|
67
|
+
}
|