@dotrino/vaultd 0.6.2 → 0.7.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/bin/dotrino-vaultd.js +13 -0
- package/package.json +1 -1
- package/src/ctl.js +17 -10
- package/src/daemon.js +77 -4
package/bin/dotrino-vaultd.js
CHANGED
|
@@ -26,4 +26,17 @@ if (process.argv.includes('--pair')) {
|
|
|
26
26
|
console.log(JSON.stringify(qr))
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// --tui: la bóveda Y su interfaz en la MISMA ventana. Donde no queda como servicio
|
|
30
|
+
// (Windows, macOS) el daemon ocupa la ventana en primer plano, así que abrir la TUI
|
|
31
|
+
// obligaba a una segunda ventana y a repetir el PATH de Node. Con esto, una sola.
|
|
32
|
+
if (process.argv.includes('--tui')) {
|
|
33
|
+
if (!process.stdout.isTTY) {
|
|
34
|
+
console.error('--tui necesita un terminal interactivo (TTY). La bóveda sigue corriendo sin él.')
|
|
35
|
+
} else {
|
|
36
|
+
const { runTui } = await import('../src/tui/app.js')
|
|
37
|
+
await runTui() // al salir de la TUI, se para todo: es la misma ventana
|
|
38
|
+
process.exit(0)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
console.log('\n(Ctrl+C para detener)')
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
|
|
6
6
|
"bin": {
|
package/src/ctl.js
CHANGED
|
@@ -83,6 +83,13 @@ function readState () {
|
|
|
83
83
|
return s
|
|
84
84
|
}
|
|
85
85
|
function alive (pid) { try { return !!pid && (process.kill(pid, 0) || true) } catch { return false } }
|
|
86
|
+
/**
|
|
87
|
+
* Campanita para que el daemon lea la petición YA. En Windows no existen SIGUSR1/SIGUSR2
|
|
88
|
+
* y `process.kill` con ellas falla: no es un error, es que ahí no hay señales. El daemon
|
|
89
|
+
* vigila la carpeta igualmente, así que la petición se atiende en cuanto se escribe el
|
|
90
|
+
* archivo; la señal solo se ahorra la latencia del watcher.
|
|
91
|
+
*/
|
|
92
|
+
function avisar (pid, sig) { try { process.kill(pid, sig) } catch (_) { /* Windows, o el daemon ya se enteró */ } }
|
|
86
93
|
function sleep (ms) { return new Promise((r) => setTimeout(r, ms)) }
|
|
87
94
|
function requireDaemon () {
|
|
88
95
|
const s = readState()
|
|
@@ -149,7 +156,7 @@ async function cmdPair (args = []) {
|
|
|
149
156
|
// La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
|
|
150
157
|
// se empareja el dispositivo.
|
|
151
158
|
writeReq('pair-request.json', service ? { service } : {})
|
|
152
|
-
|
|
159
|
+
avisar(s.pid, 'SIGUSR1')
|
|
153
160
|
|
|
154
161
|
let pair = null
|
|
155
162
|
for (let i = 0; i < 50; i++) { await sleep(100); const p = readJson(pairFile, null); if (p?.expiresAt > Date.now()) { pair = p; break } }
|
|
@@ -196,7 +203,7 @@ function cmdApprove (code) {
|
|
|
196
203
|
if (!code) { console.error('uso: dotrino-vault approve <código> (los dígitos que muestra el dispositivo)'); process.exit(2) }
|
|
197
204
|
const s = requireDaemon()
|
|
198
205
|
writeReq('approve-request.json', { code: String(code) })
|
|
199
|
-
|
|
206
|
+
avisar(s.pid, 'SIGUSR2')
|
|
200
207
|
console.log('Aprobando con el código %s… verifica con: dotrino-vault devices', code)
|
|
201
208
|
}
|
|
202
209
|
|
|
@@ -204,7 +211,7 @@ function cmdReject (deviceId) {
|
|
|
204
211
|
if (!deviceId) { console.error('uso: dotrino-vault reject <deviceId>'); process.exit(2) }
|
|
205
212
|
const s = requireDaemon()
|
|
206
213
|
writeReq('reject-request.json', { deviceId })
|
|
207
|
-
|
|
214
|
+
avisar(s.pid, 'SIGUSR2')
|
|
208
215
|
console.log('Rechazado %s.', deviceId)
|
|
209
216
|
}
|
|
210
217
|
|
|
@@ -217,7 +224,7 @@ async function cmdMembers () {
|
|
|
217
224
|
const actaFile = path.join(dataDir(), 'acta.json')
|
|
218
225
|
try { fs.rmSync(actaFile, { force: true }) } catch (_) {}
|
|
219
226
|
writeReq('dump-request.json', {})
|
|
220
|
-
|
|
227
|
+
avisar(s.pid, 'SIGUSR2')
|
|
221
228
|
let acta = null
|
|
222
229
|
for (let i = 0; i < 50; i++) { await sleep(100); const a = readJson(actaFile, null); if (a?.at) { acta = a; break } }
|
|
223
230
|
if (!acta) { console.error('El daemon no respondió.'); process.exit(1) }
|
|
@@ -255,7 +262,7 @@ async function cmdCaps (args = []) {
|
|
|
255
262
|
const actaFile = path.join(dataDir(), 'acta.json')
|
|
256
263
|
try { fs.rmSync(actaFile, { force: true }) } catch (_) {}
|
|
257
264
|
writeReq('dump-request.json', {})
|
|
258
|
-
|
|
265
|
+
avisar(s.pid, 'SIGUSR2')
|
|
259
266
|
let acta = null
|
|
260
267
|
for (let i = 0; i < 50; i++) { await sleep(100); const a = readJson(actaFile, null); if (a?.at) { acta = a; break } }
|
|
261
268
|
const m = acta?.members?.find((x) => x.id === id.toUpperCase())
|
|
@@ -269,7 +276,7 @@ async function cmdCaps (args = []) {
|
|
|
269
276
|
if (signo === '+') caps.add(cap); else caps.delete(cap)
|
|
270
277
|
}
|
|
271
278
|
writeReq('caps-request.json', { pub: m.pub, caps: [...caps] })
|
|
272
|
-
|
|
279
|
+
avisar(s.pid, 'SIGUSR2')
|
|
273
280
|
console.log('Listo. Compruébalo con: dotrino-vault members')
|
|
274
281
|
}
|
|
275
282
|
|
|
@@ -277,7 +284,7 @@ async function cmdDevices () {
|
|
|
277
284
|
const s = requireDaemon()
|
|
278
285
|
try { fs.rmSync(devFile, { force: true }) } catch (_) {}
|
|
279
286
|
writeReq('dump-request.json', {}) // de qué perfil queremos los dispositivos
|
|
280
|
-
|
|
287
|
+
avisar(s.pid, 'SIGUSR2')
|
|
281
288
|
let snap = null
|
|
282
289
|
for (let i = 0; i < 50; i++) { await sleep(100); const d = readJson(devFile, null); if (d?.at) { snap = d; break } }
|
|
283
290
|
if (!snap) { console.error('El daemon no respondió.'); process.exit(1) }
|
|
@@ -301,7 +308,7 @@ function cmdRevoke (nonce) {
|
|
|
301
308
|
if (!nonce) { console.error('uso: dotrino-vault revoke <nonce>'); process.exit(2) }
|
|
302
309
|
const s = requireDaemon()
|
|
303
310
|
writeReq('revoke-request.json', { nonce })
|
|
304
|
-
|
|
311
|
+
avisar(s.pid, 'SIGUSR2')
|
|
305
312
|
console.log('Revocación enviada para nonce=%s. El dispositivo se autoborrará al reconectar. Verifica: dotrino-vault devices', nonce)
|
|
306
313
|
}
|
|
307
314
|
|
|
@@ -349,7 +356,7 @@ async function cmdSecret (rest) {
|
|
|
349
356
|
const signalAndWaitList = async () => {
|
|
350
357
|
try { fs.rmSync(secretsListFile, { force: true }) } catch (_) {}
|
|
351
358
|
writeReq('dump-request.json', {}) // de qué perfil son los secretos
|
|
352
|
-
|
|
359
|
+
avisar(s.pid, 'SIGUSR2')
|
|
353
360
|
for (let i = 0; i < 50; i++) { await sleep(100); const d = readJson(secretsListFile, null); if (d?.at) return d }
|
|
354
361
|
console.error('El daemon no respondió.'); process.exit(1)
|
|
355
362
|
}
|
|
@@ -416,7 +423,7 @@ async function profileRequest (op, extra = {}) {
|
|
|
416
423
|
const s = requireDaemon()
|
|
417
424
|
try { fs.rmSync(profilesFile, { force: true }) } catch (_) {}
|
|
418
425
|
writeReq('profile-request.json', { op, ...extra })
|
|
419
|
-
|
|
426
|
+
avisar(s.pid, 'SIGUSR2')
|
|
420
427
|
for (let i = 0; i < 100; i++) {
|
|
421
428
|
await sleep(100)
|
|
422
429
|
const d = readJson(profilesFile, null)
|
package/src/daemon.js
CHANGED
|
@@ -23,9 +23,36 @@ import { VERSION } from './version.js'
|
|
|
23
23
|
const readJsonSafe = (f) => readJson(f, null)
|
|
24
24
|
const rm = (f) => { try { fs.rmSync(f, { force: true }) } catch (_) {} }
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* UNA sola bóveda por directorio de datos.
|
|
28
|
+
*
|
|
29
|
+
* El dir NO depende de la carpeta desde la que lances el comando: es fijo por usuario
|
|
30
|
+
* (`%LOCALAPPDATA%\\Dotrino\\vault` o `~/.local/share/dotrino/vault`). Así que lanzar el
|
|
31
|
+
* comando dos veces —en dos ventanas, sin darse cuenta— daba DOS daemons sobre los mismos
|
|
32
|
+
* datos, los dos con tu identidad y los dos conectados al proxy; el segundo pisaba el pid
|
|
33
|
+
* de `state.json`, así que el CLI solo le hablaba a uno y el otro quedaba de fantasma.
|
|
34
|
+
*
|
|
35
|
+
* El candado es el propio `state.json`: si el pid que hay sigue vivo, no arrancamos. Un
|
|
36
|
+
* pid muerto (se cortó la luz) no estorba. Con `DOTRINO_VAULT_DIR` distintos conviven
|
|
37
|
+
* cuantas quieras: lo que colisiona es el directorio, no el programa.
|
|
38
|
+
*/
|
|
39
|
+
function comprobarInstanciaUnica (dir) {
|
|
40
|
+
let s = null
|
|
41
|
+
try { s = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')) } catch (_) { return }
|
|
42
|
+
const pid = Number(s?.pid)
|
|
43
|
+
if (!pid || pid === process.pid) return
|
|
44
|
+
try { process.kill(pid, 0) } catch (_) { return } // no existe: el candado es de un muerto
|
|
45
|
+
console.error('Ya hay una bóveda corriendo sobre estos datos (proceso %d).', pid)
|
|
46
|
+
console.error(' datos: %s', dir)
|
|
47
|
+
console.error('Dos bóvedas sobre el mismo directorio se pisan: párá la otra, o usa')
|
|
48
|
+
console.error('DOTRINO_VAULT_DIR para darle a esta un directorio propio.')
|
|
49
|
+
process.exit(3)
|
|
50
|
+
}
|
|
51
|
+
|
|
26
52
|
export async function runDaemon () {
|
|
27
53
|
const dir = dataDir()
|
|
28
54
|
const proxyUrl = process.env.PROXY_URL || 'wss://proxy.dotrino.com'
|
|
55
|
+
comprobarInstanciaUnica(dir)
|
|
29
56
|
|
|
30
57
|
const pendingEnrollFile = path.join(dir, 'pending-enroll.json')
|
|
31
58
|
// Cuando un dispositivo pide enrolarse, exponemos su deviceId (y a QUÉ perfil
|
|
@@ -71,7 +98,7 @@ export async function runDaemon () {
|
|
|
71
98
|
// --- SIGUSR1: iniciar emparejamiento ---
|
|
72
99
|
const pairFile = path.join(dir, 'pair.json')
|
|
73
100
|
const pairReqFile = path.join(dir, 'pair-request.json')
|
|
74
|
-
|
|
101
|
+
async function atenderEmparejamiento () {
|
|
75
102
|
try {
|
|
76
103
|
rm(pendingEnrollFile)
|
|
77
104
|
// Pairing manual por CLI = gesto explícito del dueño → cert de identidad completo.
|
|
@@ -103,7 +130,7 @@ export async function runDaemon () {
|
|
|
103
130
|
} catch (e) {
|
|
104
131
|
console.error('[vault] no se pudo iniciar emparejamiento:', e.message)
|
|
105
132
|
}
|
|
106
|
-
}
|
|
133
|
+
}
|
|
107
134
|
|
|
108
135
|
// --- SIGUSR2: approve / reject / revoke / secretos / perfiles + volcados ---
|
|
109
136
|
const devFile = path.join(dir, 'devices.json')
|
|
@@ -137,7 +164,7 @@ export async function runDaemon () {
|
|
|
137
164
|
}
|
|
138
165
|
}
|
|
139
166
|
|
|
140
|
-
|
|
167
|
+
async function atenderPeticiones () {
|
|
141
168
|
try {
|
|
142
169
|
const appr = readJsonSafe(approveReqFile)
|
|
143
170
|
if (appr?.code) {
|
|
@@ -201,7 +228,53 @@ export async function runDaemon () {
|
|
|
201
228
|
} catch (e) {
|
|
202
229
|
console.error('[vault] error en señal de control:', e.message)
|
|
203
230
|
}
|
|
204
|
-
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// --- CÓMO LLEGAN LAS ÓRDENES DEL CLI ---
|
|
234
|
+
//
|
|
235
|
+
// El CLI escribe un archivo de petición en el dir de datos y hay que despertar al
|
|
236
|
+
// daemon para que lo lea. Durante mucho tiempo eso fue una SEÑAL (SIGUSR1/SIGUSR2), y
|
|
237
|
+
// por eso el vault no servía de nada en Windows: ahí esas señales no existen, así que
|
|
238
|
+
// `pair`, `approve`, `members` y la TUI no podían pedirle nada al daemon. El `status`
|
|
239
|
+
// engañaba, porque solo lee un archivo.
|
|
240
|
+
//
|
|
241
|
+
// Ahora la campanita es VIGILAR LA CARPETA (`fs.watch`), que funciona en los tres
|
|
242
|
+
// sistemas, más un repaso periódico por si el watcher se pierde un evento (pasa en
|
|
243
|
+
// carpetas de red y en algunos montajes). Las señales se mantienen donde existen: no
|
|
244
|
+
// estorban y hacen que la respuesta sea inmediata.
|
|
245
|
+
const REPASO_MS = 2000
|
|
246
|
+
let atendiendo = false
|
|
247
|
+
async function atender () {
|
|
248
|
+
if (atendiendo) return // una a la vez: las peticiones se consumen y se borran
|
|
249
|
+
atendiendo = true
|
|
250
|
+
try {
|
|
251
|
+
// OJO: `fs.watch` avisa al CREAR el archivo, antes de que el CLI termine de
|
|
252
|
+
// escribirlo. Si se lee a medias, el JSON no parsea y la petición se pierde con
|
|
253
|
+
// sus datos — y el emparejamiento salía como si fuera de un dispositivo normal,
|
|
254
|
+
// sin el `--service`, emitiendo un cert con el scope equivocado. Así que solo se
|
|
255
|
+
// atiende cuando el archivo YA parsea; si no, lo recoge el repaso de 2 s.
|
|
256
|
+
if (readJsonSafe(pairReqFile)) await atenderEmparejamiento()
|
|
257
|
+
await atenderPeticiones()
|
|
258
|
+
} catch (e) { console.error('[vault] error atendiendo una petición:', e.message) }
|
|
259
|
+
finally { atendiendo = false }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
fs.watch(dir, (_ev, file) => { if (!file || /-request\.json$/.test(file)) atender() })
|
|
264
|
+
} catch (e) {
|
|
265
|
+
console.error('[vault] no se pudo vigilar %s (%s); se atenderá solo por repaso', dir, e.message)
|
|
266
|
+
}
|
|
267
|
+
const repaso = setInterval(atender, REPASO_MS)
|
|
268
|
+
repaso.unref?.()
|
|
269
|
+
|
|
270
|
+
// POSIX: la señal sigue valiendo como atajo inmediato. En Windows no existe y no pasa nada.
|
|
271
|
+
// Van por `atender()` y NO llaman directo: si no, la señal y el vigilante corren a la
|
|
272
|
+
// vez, la primera consume la petición y la segunda la lee vacía — y un `pair --service`
|
|
273
|
+
// acababa emitiendo un cert de dispositivo normal, con el scope equivocado.
|
|
274
|
+
if (process.platform !== 'win32') {
|
|
275
|
+
process.on('SIGUSR1', () => { atender() })
|
|
276
|
+
process.on('SIGUSR2', () => { atender() })
|
|
277
|
+
}
|
|
205
278
|
|
|
206
279
|
// --- apagado limpio ---
|
|
207
280
|
const shutdown = (sig) => {
|