@dotrino/vault 0.2.0 → 0.3.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 +73 -0
- package/bin/dotrino-env.js +142 -0
- package/package.json +12 -2
- package/src/config.js +26 -0
- package/src/env.js +95 -0
- package/src/service.js +20 -2
package/README.md
CHANGED
|
@@ -37,6 +37,79 @@ const machines = await vault.listMachines() // [{ sub, deviceId, label, exp, n
|
|
|
37
37
|
vault.close()
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
## Credenciales del vault en vez del `.env` (Node)
|
|
41
|
+
|
|
42
|
+
La cara "dotenv" del paquete: **cualquier proyecto Node** jala sus credenciales del
|
|
43
|
+
vault del dueño y las deja en `process.env`. En el disco del servicio **no queda
|
|
44
|
+
ningún secreto**: solo la llave del dispositivo (generada ahí, nunca sale) y un
|
|
45
|
+
certificado con scope `vault:secrets:<ns>`. Los valores viven **solo en memoria**;
|
|
46
|
+
si la máquina se compromete, revocas el cert y no había nada que robar.
|
|
47
|
+
|
|
48
|
+
### 1) Registro del cliente (una sola vez)
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# en el VAULT (tu PC): abres el emparejamiento del servicio y cargas sus secretos
|
|
52
|
+
dotrino-vault pair --service miapp # invitación con scope SOLO vault:secrets:miapp
|
|
53
|
+
dotrino-vault secret set miapp API_KEY sk-…
|
|
54
|
+
|
|
55
|
+
# en el PROYECTO/servidor: enrola esta máquina (pega la invitación)
|
|
56
|
+
npx dotrino-env enroll --ns miapp
|
|
57
|
+
# → muestra un código: dotrino-vault approve 7K3F-92Q1
|
|
58
|
+
|
|
59
|
+
# de vuelta en el VAULT: lo tipeas leyéndolo de esa pantalla
|
|
60
|
+
dotrino-vault approve 7K3F-92Q1
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
El código lo **genera el servicio** y **no viaja** por la red: el vault solo puede
|
|
64
|
+
echarlo de vuelta si un humano lo tipeó. Así, un vault falso no puede enrolarte y
|
|
65
|
+
aprobar a ciegas no enrola a nadie. Queda `~/.dotrino/service/<ns>/service-identity.json`
|
|
66
|
+
(0600) con `{ device, cert, iss, proxy, ns }`.
|
|
67
|
+
|
|
68
|
+
Es un **comando previo**, no el primer arranque de la app: el enrolamiento necesita a
|
|
69
|
+
un humano leyendo el código en esta pantalla (bajo systemd/PM2 no hay TTY y el código
|
|
70
|
+
acabaría en un log), bloquea esperando la aprobación y **escribe** en disco consumiendo
|
|
71
|
+
una invitación de un solo uso. El arranque, en cambio, solo **lee** la identidad ya
|
|
72
|
+
guardada: es idempotente y no interactúa con nadie. Corre el `enroll` donde corres el
|
|
73
|
+
`npm ci` al aprovisionar la máquina.
|
|
74
|
+
|
|
75
|
+
### 2) En el código
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
import '@dotrino/vault/config' // como `dotenv/config`, pero contra el vault (ns = DOTRINO_NS)
|
|
79
|
+
console.log(process.env.API_KEY)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
o explícito:
|
|
83
|
+
|
|
84
|
+
```js
|
|
85
|
+
import { loadEnv } from '@dotrino/vault/env'
|
|
86
|
+
const { secrets } = await loadEnv({ ns: 'miapp', required: ['API_KEY'] })
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Es **asíncrono a propósito**: el `import` bloquea el arranque (top-level await) hasta
|
|
90
|
+
que los secretos estén. Si el vault no está disponible, **espera** (reintento con
|
|
91
|
+
backoff) — un servicio sin vault no arranca, no opera con secretos viejos ni vacíos.
|
|
92
|
+
Un fallo NO transitorio (sin enrolar, cert revocado, scope equivocado) sí aborta.
|
|
93
|
+
|
|
94
|
+
Para procesos que no son Node, el CLI los inyecta en el entorno de un hijo:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
dotrino-env run --ns miapp -- ./mi-binario
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### API `@dotrino/vault/env`
|
|
101
|
+
|
|
102
|
+
- `loadEnv({ ns?, dir?, override?, wait?, required?, onRetry? }) → { ns, secrets, injected, skipped }`
|
|
103
|
+
(por defecto **no pisa** variables ya presentes en el entorno; `override: true` sí)
|
|
104
|
+
- `serviceDir(ns)`, `serviceRoot()`, `listEnrolled()`, `resolveNs(ns?)`
|
|
105
|
+
- Entorno: `DOTRINO_NS` · `DOTRINO_ENV_DIR` · `DOTRINO_ENV_HOME` · `DOTRINO_ENV_QUIET`
|
|
106
|
+
- CLI: `dotrino-env enroll|status|check|run` (`check` lista **nombres** de secretos, nunca valores)
|
|
107
|
+
|
|
108
|
+
Bajo el capó es `@dotrino/vault/service` (`enrollService` / `waitForSecrets`): petición
|
|
109
|
+
firmada por la llave del servicio + cert, respuesta **sellada** (ECDH efímero + AES-GCM,
|
|
110
|
+
el proxy no ve los valores) y **firmada por la maestra**, verificada contra la `iss`
|
|
111
|
+
pineada en el enrolamiento.
|
|
112
|
+
|
|
40
113
|
## Modelo de aprobación (seguro por diseño)
|
|
41
114
|
|
|
42
115
|
- El **dispositivo** que se enrola genera un **código aleatorio** (`makePairingCode`) y
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dotrino-env — CLI del "dotenv contra el vault".
|
|
4
|
+
*
|
|
5
|
+
* dotrino-env enroll --ns <ns> [--qr <invitación>] enrola ESTA máquina/servicio (una vez)
|
|
6
|
+
* dotrino-env status qué hay enrolado aquí
|
|
7
|
+
* dotrino-env check [--ns <ns>] pide los secretos y lista sus NOMBRES (nunca valores)
|
|
8
|
+
* dotrino-env run [--ns <ns>] -- <cmd> [args…] corre un comando con los secretos en su entorno
|
|
9
|
+
*
|
|
10
|
+
* El enrolamiento es el registro del cliente contra el vault del dueño:
|
|
11
|
+
* 1. en el vault: dotrino-vault pair --service <ns> (invitación con scope SOLO vault:secrets:<ns>)
|
|
12
|
+
* 2. aquí: dotrino-env enroll --ns <ns> (pegas la invitación; se MUESTRA un código)
|
|
13
|
+
* 3. en el vault: dotrino-vault approve <código> (lo tipeas leyéndolo de esta pantalla)
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import readline from 'node:readline/promises'
|
|
18
|
+
import { spawn } from 'node:child_process'
|
|
19
|
+
import { enrollService, readServiceIdentity } from '../src/service.js'
|
|
20
|
+
import { loadEnv, serviceDir, serviceRoot, listEnrolled, resolveNs } from '../src/env.js'
|
|
21
|
+
|
|
22
|
+
const argv = process.argv.slice(2)
|
|
23
|
+
const flag = (name) => { const i = argv.indexOf('--' + name); return i >= 0 ? argv[i + 1] : undefined }
|
|
24
|
+
const has = (name) => argv.includes('--' + name)
|
|
25
|
+
|
|
26
|
+
function help () {
|
|
27
|
+
console.log(`dotrino-env — credenciales del vault en vez del .env
|
|
28
|
+
|
|
29
|
+
enroll --ns <ns> [--qr <invitación>] [--dir <dir>]
|
|
30
|
+
Registra ESTE servicio contra el vault (una sola vez).
|
|
31
|
+
Antes, en el vault: dotrino-vault pair --service <ns>
|
|
32
|
+
Si no pasas --qr, se pide por consola (también acepta stdin).
|
|
33
|
+
|
|
34
|
+
status servicios enrolados en esta máquina
|
|
35
|
+
check [--ns <ns>] pide los secretos al vault y lista sus NOMBRES (nunca los valores)
|
|
36
|
+
run [--ns <ns>] -- <cmd> [args…]
|
|
37
|
+
ejecuta <cmd> con los secretos inyectados en su entorno
|
|
38
|
+
|
|
39
|
+
En tu código:
|
|
40
|
+
import '@dotrino/vault/config' // ns por DOTRINO_NS
|
|
41
|
+
import { loadEnv } from '@dotrino/vault/env'; await loadEnv({ ns: '<ns>' })
|
|
42
|
+
|
|
43
|
+
Entorno: DOTRINO_NS · DOTRINO_ENV_DIR · DOTRINO_ENV_HOME · DOTRINO_ENV_QUIET`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* La invitación que imprime `dotrino-vault pair` viene en tres formas: el JSON
|
|
48
|
+
* crudo, el base64url del payload, o la URL de profile con `#vault=<b64>`.
|
|
49
|
+
* Aceptamos las tres para que el operador pegue lo que tenga a mano.
|
|
50
|
+
*/
|
|
51
|
+
function parseInvite (raw) {
|
|
52
|
+
const s = String(raw || '').trim()
|
|
53
|
+
if (!s) throw new Error('invitación vacía')
|
|
54
|
+
if (s.startsWith('{')) return JSON.parse(s)
|
|
55
|
+
const b64 = s.includes('#vault=') ? s.split('#vault=')[1] : (s.includes('#') ? s.split('#').pop() : s)
|
|
56
|
+
const json = Buffer.from(b64.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
|
|
57
|
+
if (!json.trim().startsWith('{')) throw new Error('no parece una invitación del vault')
|
|
58
|
+
return JSON.parse(json)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readInvite () {
|
|
62
|
+
if (!process.stdin.isTTY) return fs.readFileSync(0, 'utf8')
|
|
63
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
64
|
+
const answer = await rl.question('Pega la invitación del vault (salida de `dotrino-vault pair --service <ns>`):\n> ')
|
|
65
|
+
rl.close()
|
|
66
|
+
return answer
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function cmdEnroll () {
|
|
70
|
+
const ns = flag('ns')
|
|
71
|
+
if (!ns) { console.error('falta --ns <ns> (el mismo del `dotrino-vault pair --service <ns>`)'); process.exit(2) }
|
|
72
|
+
const dir = flag('dir') || serviceDir(ns)
|
|
73
|
+
if (readServiceIdentity(dir) && !has('force')) {
|
|
74
|
+
console.error('ya hay un servicio enrolado en %s (usa --force para re-enrolar)', dir); process.exit(2)
|
|
75
|
+
}
|
|
76
|
+
const qr = parseInvite(flag('qr') || await readInvite())
|
|
77
|
+
|
|
78
|
+
console.log('\nEnrolando el servicio "%s" contra el vault…', ns)
|
|
79
|
+
const { cert } = await enrollService({
|
|
80
|
+
qr,
|
|
81
|
+
ns,
|
|
82
|
+
dir,
|
|
83
|
+
label: flag('label') || 'servicio:' + ns,
|
|
84
|
+
onCode: ({ deviceId, code }) => {
|
|
85
|
+
console.log('\n Dispositivo: %s', deviceId)
|
|
86
|
+
console.log(' APRUEBA en el vault tipeando este código:\n')
|
|
87
|
+
console.log(' dotrino-vault approve %s\n', code)
|
|
88
|
+
console.log(' (el vault NO conoce este código: tiene que leerlo de aquí un humano)')
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
console.log('\nListo. Identidad del servicio en: %s', path.join(dir, 'service-identity.json'))
|
|
92
|
+
console.log('Certificado con scope: %s (vence %s)', (cert.scope || []).join(', '), new Date(cert.exp).toISOString())
|
|
93
|
+
console.log('\nEn tu app: import \'@dotrino/vault/config\' (con DOTRINO_NS=%s)', ns)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function cmdStatus () {
|
|
97
|
+
const found = listEnrolled()
|
|
98
|
+
if (!found.length) {
|
|
99
|
+
console.log('Ningún servicio enrolado en %s\n Enrola uno: dotrino-env enroll --ns <ns>', serviceRoot())
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
for (const ns of found) {
|
|
103
|
+
const id = readServiceIdentity(serviceDir(ns))
|
|
104
|
+
const exp = id?.cert?.exp
|
|
105
|
+
console.log('%s\n dir: %s\n vault: %s…\n scope: %s\n cert: vence %s',
|
|
106
|
+
ns, serviceDir(ns), String(id.iss).slice(0, 24), (id.cert?.scope || []).join(', '),
|
|
107
|
+
exp ? new Date(exp).toISOString() : '?')
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function cmdCheck () {
|
|
112
|
+
const ns = resolveNs(flag('ns'))
|
|
113
|
+
const { secrets } = await loadEnv({ ns, wait: false })
|
|
114
|
+
const keys = Object.keys(secrets)
|
|
115
|
+
console.log('ns "%s": %d secreto(s)%s', ns, keys.length, keys.length ? ':' : '')
|
|
116
|
+
for (const k of keys) console.log(' ' + k) // NUNCA los valores
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function cmdRun () {
|
|
120
|
+
const sep = argv.indexOf('--')
|
|
121
|
+
const cmd = sep >= 0 ? argv.slice(sep + 1) : []
|
|
122
|
+
if (!cmd.length) { console.error('uso: dotrino-env run [--ns <ns>] -- <cmd> [args…]'); process.exit(2) }
|
|
123
|
+
await loadEnv({ ns: flag('ns') })
|
|
124
|
+
const child = spawn(cmd[0], cmd.slice(1), { stdio: 'inherit', env: process.env })
|
|
125
|
+
child.on('exit', (code, signal) => process.exit(signal ? 1 : (code ?? 0)))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const run = async () => {
|
|
129
|
+
switch (argv[0]) {
|
|
130
|
+
case 'enroll': return cmdEnroll()
|
|
131
|
+
case 'status': return cmdStatus()
|
|
132
|
+
case 'check': return cmdCheck()
|
|
133
|
+
case 'run': return cmdRun()
|
|
134
|
+
case undefined:
|
|
135
|
+
case 'help':
|
|
136
|
+
case '--help':
|
|
137
|
+
case '-h': return help()
|
|
138
|
+
default: console.error('comando desconocido: %s\n', argv[0]); help(); process.exit(2)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
run().catch((e) => { console.error('\n[dotrino-env] ' + e.message); process.exit(1) })
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vault",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye el cliente de SERVICIO (Node): un
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"module": "src/index.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"dotrino-env": "bin/dotrino-env.js"
|
|
10
|
+
},
|
|
8
11
|
"exports": {
|
|
9
12
|
".": {
|
|
10
13
|
"import": "./src/index.js"
|
|
@@ -12,6 +15,12 @@
|
|
|
12
15
|
"./service": {
|
|
13
16
|
"import": "./src/service.js"
|
|
14
17
|
},
|
|
18
|
+
"./env": {
|
|
19
|
+
"import": "./src/env.js"
|
|
20
|
+
},
|
|
21
|
+
"./config": {
|
|
22
|
+
"import": "./src/config.js"
|
|
23
|
+
},
|
|
15
24
|
"./sealed": {
|
|
16
25
|
"import": "./src/sealed.js"
|
|
17
26
|
},
|
|
@@ -21,6 +30,7 @@
|
|
|
21
30
|
},
|
|
22
31
|
"files": [
|
|
23
32
|
"src",
|
|
33
|
+
"bin",
|
|
24
34
|
"README.md",
|
|
25
35
|
"LICENSE"
|
|
26
36
|
],
|
package/src/config.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `import '@dotrino/vault/config'` — el equivalente de `import 'dotenv/config'`,
|
|
3
|
+
* pero contra el vault del dueño.
|
|
4
|
+
*
|
|
5
|
+
* Bloquea el arranque (top-level await) hasta que los secretos del ns estén en
|
|
6
|
+
* `process.env`. Si el vault no está disponible, ESPERA (reintento con backoff):
|
|
7
|
+
* la regla del ecosistema es que un servicio sin vault no arranca — no opera con
|
|
8
|
+
* secretos viejos ni vacíos. Un fallo NO transitorio (sin enrolar, cert revocado,
|
|
9
|
+
* scope equivocado) sí aborta el proceso.
|
|
10
|
+
*
|
|
11
|
+
* Config por entorno:
|
|
12
|
+
* DOTRINO_NS namespace de secretos (si no, el único enrolado en la máquina)
|
|
13
|
+
* DOTRINO_ENV_DIR directorio de la identidad del servicio (si no, ~/.dotrino/service/<ns>)
|
|
14
|
+
* DOTRINO_ENV_QUIET '1' para no imprimir la línea de arranque
|
|
15
|
+
*/
|
|
16
|
+
import { loadEnv } from './env.js'
|
|
17
|
+
|
|
18
|
+
const quiet = process.env.DOTRINO_ENV_QUIET === '1'
|
|
19
|
+
|
|
20
|
+
const { ns, injected } = await loadEnv({
|
|
21
|
+
onRetry: (e, ms) => {
|
|
22
|
+
if (!quiet) console.error('[dotrino-env] vault no disponible (%s); reintentando en %ds…', e.message, Math.round(ms / 1000))
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
if (!quiet) console.error('[dotrino-env] %d secreto(s) del ns "%s" cargados en process.env', injected.length, ns)
|
package/src/env.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@dotrino/vault/env` — el "dotenv contra el vault".
|
|
3
|
+
*
|
|
4
|
+
* Un proyecto Node cualquiera obtiene sus credenciales del vault del dueño en
|
|
5
|
+
* vez de llevarlas en un `.env`:
|
|
6
|
+
*
|
|
7
|
+
* import { loadEnv } from '@dotrino/vault/env'
|
|
8
|
+
* await loadEnv({ ns: 'miapp' }) // → process.env.API_KEY, …
|
|
9
|
+
*
|
|
10
|
+
* o, con la forma clásica de dotenv (side-effect, ns por `DOTRINO_NS`):
|
|
11
|
+
*
|
|
12
|
+
* import '@dotrino/vault/config'
|
|
13
|
+
*
|
|
14
|
+
* Lo que queda en el disco del servicio NO es un secreto: es la llave del
|
|
15
|
+
* dispositivo (generada aquí, nunca sale) y un certificado con scope
|
|
16
|
+
* `vault:secrets:<ns>`. Los valores solo viven en memoria del proceso; si la
|
|
17
|
+
* máquina se compromete, se revoca el cert y no había nada que robar.
|
|
18
|
+
*
|
|
19
|
+
* Enrolar una vez: npx dotrino-env enroll --ns miapp (ver bin/dotrino-env.js)
|
|
20
|
+
*/
|
|
21
|
+
import fs from 'node:fs'
|
|
22
|
+
import os from 'node:os'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
import { fetchSecrets, waitForSecrets, readServiceIdentity } from './service.js'
|
|
25
|
+
import { isValidSecretsNs } from './protocol.js'
|
|
26
|
+
|
|
27
|
+
/** Raíz donde viven las identidades de servicio de esta máquina/usuario. */
|
|
28
|
+
export function serviceRoot () {
|
|
29
|
+
return process.env.DOTRINO_ENV_HOME || path.join(os.homedir(), '.dotrino', 'service')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Directorio de la identidad del servicio `ns` (`DOTRINO_ENV_DIR` lo pisa). */
|
|
33
|
+
export function serviceDir (ns) {
|
|
34
|
+
if (process.env.DOTRINO_ENV_DIR) return process.env.DOTRINO_ENV_DIR
|
|
35
|
+
if (!isValidSecretsNs(ns)) throw new Error('ns inválido (usa [a-z0-9-]{1,32}, p. ej. "miapp")')
|
|
36
|
+
return path.join(serviceRoot(), ns)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Namespaces ya enrolados en esta máquina. */
|
|
40
|
+
export function listEnrolled () {
|
|
41
|
+
let names = []
|
|
42
|
+
try { names = fs.readdirSync(serviceRoot()) } catch (_) { return [] }
|
|
43
|
+
return names.filter((ns) => isValidSecretsNs(ns) && readServiceIdentity(path.join(serviceRoot(), ns)))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resuelve el ns cuando no se pasa explícito: `DOTRINO_NS`, y si no, el único
|
|
48
|
+
* enrolado en esta máquina. Con varios, exige elegir (no adivinamos).
|
|
49
|
+
*/
|
|
50
|
+
export function resolveNs (ns) {
|
|
51
|
+
ns = ns || process.env.DOTRINO_NS
|
|
52
|
+
if (ns) {
|
|
53
|
+
if (!isValidSecretsNs(ns)) throw new Error('ns inválido: ' + ns)
|
|
54
|
+
return ns
|
|
55
|
+
}
|
|
56
|
+
const found = listEnrolled()
|
|
57
|
+
if (found.length === 1) return found[0]
|
|
58
|
+
if (found.length === 0) {
|
|
59
|
+
throw new Error('no hay ningún servicio enrolado en esta máquina: corre `npx dotrino-env enroll --ns <tu-app>`')
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`hay varios servicios enrolados (${found.join(', ')}): elige uno con DOTRINO_NS=<ns> o loadEnv({ ns })`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Trae los secretos del ns desde el vault y los pone en `process.env`.
|
|
66
|
+
*
|
|
67
|
+
* @param {Object} [opts]
|
|
68
|
+
* @param {string} [opts.ns] Namespace (por defecto: `DOTRINO_NS` o el único enrolado).
|
|
69
|
+
* @param {string} [opts.dir] Dónde está `service-identity.json` (por defecto: `serviceDir(ns)`).
|
|
70
|
+
* @param {boolean} [opts.override] `true` = pisa variables ya presentes en el entorno (default: no).
|
|
71
|
+
* @param {boolean} [opts.wait] `true` (default) = si el vault no está, ESPERA (reintenta) en vez de fallar.
|
|
72
|
+
* @param {string[]} [opts.required] Claves que deben venir; si falta alguna, lanza.
|
|
73
|
+
* @param {(e:Error, ms:number)=>void} [opts.onRetry]
|
|
74
|
+
* @returns {Promise<{ns:string, secrets:Record<string,string>, injected:string[], skipped:string[]}>}
|
|
75
|
+
*/
|
|
76
|
+
export async function loadEnv ({ ns, dir, override = false, wait = true, required = [], onRetry } = {}) {
|
|
77
|
+
ns = resolveNs(ns)
|
|
78
|
+
dir = dir || serviceDir(ns)
|
|
79
|
+
const load = wait ? waitForSecrets : fetchSecrets
|
|
80
|
+
const secrets = await load({ dir, ns, onRetry })
|
|
81
|
+
|
|
82
|
+
const missing = required.filter((k) => !(k in secrets))
|
|
83
|
+
if (missing.length) {
|
|
84
|
+
throw new Error(`faltan secretos en el ns "${ns}": ${missing.join(', ')} (agrégalos con \`dotrino-vault secret set ${ns} <CLAVE> <valor>\`)`)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const injected = []
|
|
88
|
+
const skipped = []
|
|
89
|
+
for (const [k, v] of Object.entries(secrets)) {
|
|
90
|
+
if (!override && k in process.env) { skipped.push(k); continue }
|
|
91
|
+
process.env[k] = String(v)
|
|
92
|
+
injected.push(k)
|
|
93
|
+
}
|
|
94
|
+
return { ns, secrets, injected, skipped }
|
|
95
|
+
}
|
package/src/service.js
CHANGED
|
@@ -60,11 +60,29 @@ function installNodeGlobals () {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
async function freshClient (proxyUrl) {
|
|
63
|
+
async function freshClient (proxyUrl, connectTimeoutMs = 20000) {
|
|
64
64
|
installNodeGlobals()
|
|
65
65
|
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
66
66
|
const client = new WebSocketProxyClient({ url: proxyUrl, enableWebRTC: false, autoReconnect: false })
|
|
67
|
-
|
|
67
|
+
// connect() del cliente solo se resuelve con 'connected' y solo rechaza en el
|
|
68
|
+
// evento 'error' de transporte: si el socket cierra LIMPIO antes de 'connected'
|
|
69
|
+
// (p.ej. el proxy banea la IP y envía close 1008), la promesa quedaría colgada
|
|
70
|
+
// para siempre y waitForSecrets no reintentaría. Le ponemos un timeout propio.
|
|
71
|
+
let timer
|
|
72
|
+
const timeout = new Promise((_, reject) => {
|
|
73
|
+
timer = setTimeout(() => reject(new Error('timeout conectando al proxy')), connectTimeoutMs)
|
|
74
|
+
})
|
|
75
|
+
try {
|
|
76
|
+
await Promise.race([client.connect(), timeout])
|
|
77
|
+
} catch (e) {
|
|
78
|
+
try { client.close() } catch (_) {}
|
|
79
|
+
// El 'error' de transporte del cliente puede llegar como un Event sin
|
|
80
|
+
// `message` → sin esto el operador ve una línea de error vacía.
|
|
81
|
+
const why = e?.message || e?.type || 'error de transporte'
|
|
82
|
+
throw new Error(`no se pudo conectar al proxy ${proxyUrl}: ${why}`)
|
|
83
|
+
} finally {
|
|
84
|
+
clearTimeout(timer)
|
|
85
|
+
}
|
|
68
86
|
return client
|
|
69
87
|
}
|
|
70
88
|
|