@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.
@@ -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.6.2",
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
- process.kill(s.pid, 'SIGUSR1')
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
- process.kill(s.pid, 'SIGUSR2')
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
- process.kill(s.pid, 'SIGUSR2')
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
- process.kill(s.pid, 'SIGUSR2')
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
- process.kill(s.pid, 'SIGUSR2')
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
- process.kill(s.pid, 'SIGUSR2')
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
- process.kill(s.pid, 'SIGUSR2')
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
- process.kill(s.pid, 'SIGUSR2')
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
- process.kill(s.pid, 'SIGUSR2')
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
- process.kill(s.pid, 'SIGUSR2')
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
- process.on('SIGUSR1', () => {
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
- process.on('SIGUSR2', async () => {
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) => {