@dotrino/vaultd 0.26.2 → 0.38.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/src/ctl.js CHANGED
@@ -25,6 +25,8 @@ import path from 'node:path'
25
25
  import { execFileSync } from 'node:child_process'
26
26
  import { pubkeyId } from '@dotrino/identity/capabilities'
27
27
  import { dataDir, readJson } from './paths.js'
28
+ import { assertVar } from './secretsStore.js'
29
+ import { parseEnvText, PAIR_RE } from '../lib/src/envtext.js'
28
30
  import { qrToString } from './qr.js'
29
31
  import { encodeInvite, inviteUrl } from '../lib/src/invite.js'
30
32
  import { VERSION } from './version.js'
@@ -49,7 +51,18 @@ function takeProfileFlag (args) {
49
51
  }
50
52
  /** Campo `profile` de las peticiones al daemon (omitido = perfil activo). */
51
53
  const withProfile = (obj) => (PROFILE ? { ...obj, profile: PROFILE } : obj)
52
- const writeReq = (name, obj) => fs.writeFileSync(path.join(dir, name), JSON.stringify(withProfile({ ...obj, at: Date.now() })), { mode: 0o600 })
54
+ /**
55
+ * Deja una petición para el daemon. ATÓMICA (escribir aparte y renombrar): el daemon
56
+ * vigila la carpeta con `fs.watch`, que avisa al CREAR el archivo y no al terminar de
57
+ * escribirlo, así que escrito en el sitio se puede leer a medias — y una petición que no
58
+ * parsea se pierde con todos sus datos.
59
+ */
60
+ const writeReq = (name, obj) => {
61
+ const dest = path.join(dir, name)
62
+ const tmp = dest + '.tmp'
63
+ fs.writeFileSync(tmp, JSON.stringify(withProfile({ ...obj, at: Date.now() })), { mode: 0o600 })
64
+ fs.renameSync(tmp, dest)
65
+ }
53
66
 
54
67
  const R = '\x1b[31m', B = '\x1b[1m', Z = '\x1b[0m' // rojo / negrita / reset
55
68
  // La versión se inyecta en build (esbuild --define); en dev cae a 'dev'.
@@ -60,13 +73,13 @@ const R = '\x1b[31m', B = '\x1b[1m', Z = '\x1b[0m' // rojo / negrita / reset
60
73
  * contenedor, así que decir siempre «systemctl» manda a la mitad de la gente a un
61
74
  * comando que no existe.
62
75
  */
63
- const EN_DOCKER = !!process.env.DOTRINO_IN_DOCKER
64
- const START_HINT = EN_DOCKER
76
+ const IN_DOCKER = !!process.env.DOTRINO_IN_DOCKER
77
+ const START_HINT = IN_DOCKER
65
78
  ? 'docker start dotrino-vault'
66
79
  : process.platform === 'linux'
67
80
  ? 'systemctl --user start dotrino-vault'
68
81
  : 'dotrino-vaultd (o: npx -y @dotrino/vaultd)'
69
- const RESTART_HINT = EN_DOCKER
82
+ const RESTART_HINT = IN_DOCKER
70
83
  ? 'docker restart dotrino-vault'
71
84
  : process.platform === 'linux'
72
85
  ? 'systemctl --user restart dotrino-vault'
@@ -88,7 +101,7 @@ function alive (pid) { try { return !!pid && (process.kill(pid, 0) || true) } ca
88
101
  * vigila la carpeta igualmente, así que la petición se atiende en cuanto se escribe el
89
102
  * archivo; la señal solo se ahorra la latencia del watcher.
90
103
  */
91
- function avisar (pid, sig) { try { process.kill(pid, sig) } catch (_) { /* Windows, o el daemon ya se enteró */ } }
104
+ function sendSignal (pid, sig) { try { process.kill(pid, sig) } catch (_) { /* Windows, o el daemon ya se enteró */ } }
92
105
  function sleep (ms) { return new Promise((r) => setTimeout(r, ms)) }
93
106
  function requireDaemon () {
94
107
  const s = readState()
@@ -138,6 +151,20 @@ function showChallenge (pe) {
138
151
  console.log(' Si no reconoces este dispositivo: dotrino-vault reject %s\n', pe.deviceId)
139
152
  }
140
153
 
154
+ /**
155
+ * EL CANDADO. Un perfil con contraseña y bloqueado no se ve ni se toca desde esta consola:
156
+ * el daemon contesta `locked` y sin contenido, y aquí se corta con un mensaje que dice qué
157
+ * hacer. Los aparatos ya emparejados siguen atendidos — lo que está cerrado es esta
158
+ * pantalla, no la bóveda.
159
+ */
160
+ function assertOpen (d) {
161
+ if (d?.locked) {
162
+ console.error('Perfil bloqueado. Ábrelo con: dotrino-vault unlock')
163
+ process.exit(1)
164
+ }
165
+ return d
166
+ }
167
+
141
168
  async function cmdPair (args = []) {
142
169
  const s = requireDaemon()
143
170
  try { fs.rmSync(pairFile, { force: true }) } catch (_) {}
@@ -184,10 +211,15 @@ async function cmdPair (args = []) {
184
211
  // La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
185
212
  // se empareja el dispositivo.
186
213
  writeReq('pair-request.json', { ...(service ? { service } : {}), ...(adopt ? { mode: 'adopt' } : {}) })
187
- avisar(s.pid, 'SIGUSR1')
214
+ sendSignal(s.pid, 'SIGUSR1')
188
215
 
189
216
  let pair = null
190
- for (let i = 0; i < 50; i++) { await sleep(100); const p = readJson(pairFile, null); if (p?.expiresAt > Date.now()) { pair = p; break } }
217
+ for (let i = 0; i < 50; i++) {
218
+ await sleep(100)
219
+ const p = readJson(pairFile, null)
220
+ assertOpen(p) // el candado se contesta por el mismo archivo, para no dejar esperando
221
+ if (p?.expiresAt > Date.now()) { pair = p; break }
222
+ }
191
223
  if (!pair) { console.error('No se recibió respuesta del daemon para el emparejamiento.'); process.exit(1) }
192
224
 
193
225
  // Una sola forma para las dos cosas: la invitación compacta (base64url de ~100
@@ -237,7 +269,7 @@ function cmdApprove (code) {
237
269
  if (!code) { console.error('uso: dotrino-vault approve <código> (los dígitos que muestra el dispositivo)'); process.exit(2) }
238
270
  const s = requireDaemon()
239
271
  writeReq('approve-request.json', { code: String(code) })
240
- avisar(s.pid, 'SIGUSR2')
272
+ sendSignal(s.pid, 'SIGUSR2')
241
273
  console.log('Aprobando con el código %s… verifica con: dotrino-vault devices', code)
242
274
  }
243
275
 
@@ -245,7 +277,7 @@ function cmdReject (deviceId) {
245
277
  if (!deviceId) { console.error('uso: dotrino-vault reject <deviceId>'); process.exit(2) }
246
278
  const s = requireDaemon()
247
279
  writeReq('reject-request.json', { deviceId })
248
- avisar(s.pid, 'SIGUSR2')
280
+ sendSignal(s.pid, 'SIGUSR2')
249
281
  console.log('Rechazado %s.', deviceId)
250
282
  }
251
283
 
@@ -265,12 +297,13 @@ async function cmdMe () {
265
297
  const meFile = path.join(dir, 'me.json')
266
298
  try { fs.rmSync(meFile, { force: true }) } catch (_) {}
267
299
  writeReq('me-request.json', {})
268
- avisar(s.pid, 'SIGUSR2')
300
+ sendSignal(s.pid, 'SIGUSR2')
269
301
  let dump = null
270
302
  for (let n = 0; n < 50; n++) { await sleep(100); const d = readJson(meFile, null); if (d?.at) { dump = d; break } }
271
303
  // El volcado es contenido del usuario: se lee y se BORRA, no se queda ahí suelto.
272
304
  try { fs.rmSync(meFile, { force: true }) } catch (_) {}
273
305
  if (!dump) { console.error('La bóveda no respondió. ¿Está corriendo? dotrino-vault status'); process.exit(1) }
306
+ assertOpen(dump)
274
307
 
275
308
  const me = dump.me
276
309
  if (!me) {
@@ -279,8 +312,8 @@ async function cmdMe () {
279
312
  return
280
313
  }
281
314
 
282
- const cuando = me.updatedAt ? new Date(me.updatedAt).toLocaleString() : '—'
283
- console.log('\n%sPerfil%s · actualizado %s\n', B, Z, cuando)
315
+ const when = me.updatedAt ? new Date(me.updatedAt).toLocaleString() : '—'
316
+ console.log('\n%sPerfil%s · actualizado %s\n', B, Z, when)
284
317
  console.log(' nombre : %s', me.nickname || '(sin nombre)')
285
318
  console.log(' foto : %s', me.avatar
286
319
  ? `sí · ${me.avatar.type || 'desconocido'} · ${(me.avatar.bytes / 1024).toFixed(1)} KB`
@@ -289,17 +322,17 @@ async function cmdMe () {
289
322
  // Los campos estándar. `visible` es del usuario: teléfono y dirección nacen ocultos.
290
323
  const STD = [['nombres', 'nombres'], ['apellidos', 'apellidos'], ['email', 'correo'],
291
324
  ['telefono', 'teléfono'], ['direccion', 'dirección']]
292
- const puestos = STD.filter(([k]) => me[k])
293
- if (puestos.length) {
325
+ const filled = STD.filter(([k]) => me[k])
326
+ if (filled.length) {
294
327
  console.log('')
295
- for (const [k, etiqueta] of puestos) {
296
- console.log(' %s: %s%s', etiqueta.padEnd(12), me[k], me[k + 'Visible'] === false ? ' (oculto)' : '')
328
+ for (const [k, label] of filled) {
329
+ console.log(' %s: %s%s', label.padEnd(12), me[k], me[k + 'Visible'] === false ? ' (oculto)' : '')
297
330
  }
298
331
  }
299
- for (const [titulo, lista] of [['Enlaces', me.links], ['Otros datos', me.fields]]) {
300
- if (!Array.isArray(lista) || !lista.length) continue
301
- console.log('\n %s:', titulo)
302
- for (const x of lista) console.log(' %s %s%s', (x.type || x.label || '').padEnd(12), x.value, x.visible === false ? ' (oculto)' : '')
332
+ for (const [title, list] of [['Enlaces', me.links], ['Otros datos', me.fields]]) {
333
+ if (!Array.isArray(list) || !list.length) continue
334
+ console.log('\n %s:', title)
335
+ for (const x of list) console.log(' %s %s%s', (x.type || x.label || '').padEnd(12), x.value, x.visible === false ? ' (oculto)' : '')
303
336
  }
304
337
 
305
338
  console.log('')
@@ -311,30 +344,31 @@ async function cmdMe () {
311
344
  */
312
345
  async function cmdMembers () {
313
346
  const s = requireDaemon()
314
- const actaFile = path.join(dataDir(), 'acta.json')
315
- try { fs.rmSync(actaFile, { force: true }) } catch (_) {}
347
+ const recordFile = path.join(dataDir(), 'acta.json')
348
+ try { fs.rmSync(recordFile, { force: true }) } catch (_) {}
316
349
  writeReq('dump-request.json', {})
317
- avisar(s.pid, 'SIGUSR2')
318
- let acta = null
319
- for (let i = 0; i < 50; i++) { await sleep(100); const a = readJson(actaFile, null); if (a?.at) { acta = a; break } }
320
- if (!acta) { console.error('El daemon no respondió.'); process.exit(1) }
321
- if (!acta.members?.length) { console.log('Este perfil todavía no tiene acta.'); return }
350
+ sendSignal(s.pid, 'SIGUSR2')
351
+ let record = null
352
+ for (let i = 0; i < 50; i++) { await sleep(100); const a = readJson(recordFile, null); if (a?.at) { record = a; break } }
353
+ if (!record) { console.error('El daemon no respondió.'); process.exit(1) }
354
+ assertOpen(record)
355
+ if (!record.members?.length) { console.log('Este perfil todavía no tiene acta.'); return }
322
356
 
323
357
  const CAP = { sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves', admin: `${B}administra el perfil${Z}` }
324
358
  // El nombre del perfil es una pubkey JWK. Recortarla no la hace legible: la deja
325
359
  // pareciendo un error (`{"key_ops":["verify"],"e…`). Se muestra su huella corta, la
326
360
  // misma que se enseña al emparejar y en la lista de miembros.
327
- const perfilId = await deviceIdOf(acta.profileId).catch(() => '????-????')
328
- console.log('\n%sPerfil%s %s · acta #%d\n', B, Z, perfilId, acta.seq)
329
- for (const m of acta.members) {
330
- const quien = m.label || m.id
331
- const marcas = [
361
+ const profileId = await deviceIdOf(record.profileId).catch(() => '????-????')
362
+ console.log('\n%sPerfil%s %s · acta #%d\n', B, Z, profileId, record.seq)
363
+ for (const m of record.members) {
364
+ const who = m.label || m.id
365
+ const marks = [
332
366
  m.isMaster ? `${B}Master${Z}` : null,
333
367
  m.isMe ? 'este dispositivo' : null,
334
368
  m.cn ? `servicio «${m.cn}»` : null
335
369
  ].filter(Boolean)
336
370
  const caps = m.caps.length ? m.caps.map((c) => CAP[c] || c).join(', ') : '(sin permisos)'
337
- console.log(' %s %s%s\n %s', m.id, quien, marcas.length ? ' [' + marcas.join(' · ') + ']' : '', caps)
371
+ console.log(' %s %s%s\n %s', m.id, who, marks.length ? ' [' + marks.join(' · ') + ']' : '', caps)
338
372
  }
339
373
  console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra')
340
374
  console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
@@ -350,55 +384,56 @@ async function cmdMembers () {
350
384
  * tener que revocar y volver a emparejar.
351
385
  */
352
386
  async function cmdLabel (args = []) {
353
- const [id, ...resto] = args
354
- const nombre = resto.join(' ').trim()
355
- if (!id || !nombre) {
387
+ const [id, ...rest] = args
388
+ const name = rest.join(' ').trim()
389
+ if (!id || !name) {
356
390
  console.error('uso: dotrino-vault label <ID> <nombre> (p.ej. label AB12-CD34 "Teléfono de casa")')
357
391
  process.exit(2)
358
392
  }
359
- const m = await buscarMiembro(id)
360
- writeReq('label-request.json', { pub: m.pub, label: nombre })
361
- avisar(requireDaemon().pid, 'SIGUSR2')
362
- console.log('Listo: %s ahora se llama «%s». Compruébalo con: dotrino-vault members', m.id, nombre.slice(0, 60))
393
+ const m = await findMember(id)
394
+ writeReq('label-request.json', { pub: m.pub, label: name })
395
+ sendSignal(requireDaemon().pid, 'SIGUSR2')
396
+ console.log('Listo: %s ahora se llama «%s». Compruébalo con: dotrino-vault members', m.id, name.slice(0, 60))
363
397
  }
364
398
 
365
399
  /** Busca un miembro del acta por su identificador (AB12-CD34) o se rinde con un mensaje claro. */
366
- async function buscarMiembro (id) {
400
+ async function findMember (id) {
367
401
  const s = requireDaemon()
368
- const actaFile = path.join(dataDir(), 'acta.json')
369
- try { fs.rmSync(actaFile, { force: true }) } catch (_) {}
402
+ const recordFile = path.join(dataDir(), 'acta.json')
403
+ try { fs.rmSync(recordFile, { force: true }) } catch (_) {}
370
404
  writeReq('dump-request.json', {})
371
- avisar(s.pid, 'SIGUSR2')
372
- let acta = null
373
- for (let i = 0; i < 50; i++) { await sleep(100); const a = readJson(actaFile, null); if (a?.at) { acta = a; break } }
374
- const m = acta?.members?.find((x) => x.id === String(id).toUpperCase())
405
+ sendSignal(s.pid, 'SIGUSR2')
406
+ let record = null
407
+ for (let i = 0; i < 50; i++) { await sleep(100); const a = readJson(recordFile, null); if (a?.at) { record = a; break } }
408
+ assertOpen(record)
409
+ const m = record?.members?.find((x) => x.id === String(id).toUpperCase())
375
410
  if (!m) { console.error('No hay ningún dispositivo con ese identificador. Míralos con: dotrino-vault members'); process.exit(1) }
376
411
  return m
377
412
  }
378
413
 
379
414
  /** `dotrino-vault caps <ID> ±permiso` — cambia lo que puede hacer un dispositivo. */
380
415
  async function cmdCaps (args = []) {
381
- const [id, ...cambios] = args
382
- if (!id || !cambios.length) {
416
+ const [id, ...changes] = args
417
+ if (!id || !changes.length) {
383
418
  console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra')
384
419
  process.exit(2)
385
420
  }
386
- const NOMBRE = {
421
+ const CAP_BY_WORD = {
387
422
  firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin',
388
423
  sign: 'sign', store: 'store', read: 'read', admin: 'admin'
389
424
  }
390
425
  const s = requireDaemon()
391
- const m = await buscarMiembro(id)
426
+ const m = await findMember(id)
392
427
 
393
428
  const caps = new Set(m.caps)
394
- for (const c of cambios) {
395
- const signo = c[0]
396
- const cap = NOMBRE[c.slice(1).toLowerCase()]
397
- if (!cap || (signo !== '+' && signo !== '-')) { console.error('permiso no reconocido: %s', c); process.exit(2) }
398
- if (signo === '+') caps.add(cap); else caps.delete(cap)
429
+ for (const c of changes) {
430
+ const sign = c[0]
431
+ const cap = CAP_BY_WORD[c.slice(1).toLowerCase()]
432
+ if (!cap || (sign !== '+' && sign !== '-')) { console.error('permiso no reconocido: %s', c); process.exit(2) }
433
+ if (sign === '+') caps.add(cap); else caps.delete(cap)
399
434
  }
400
435
  writeReq('caps-request.json', { pub: m.pub, caps: [...caps] })
401
- avisar(s.pid, 'SIGUSR2')
436
+ sendSignal(s.pid, 'SIGUSR2')
402
437
  console.log('Listo. Compruébalo con: dotrino-vault members')
403
438
  }
404
439
 
@@ -406,25 +441,26 @@ async function cmdDevices () {
406
441
  const s = requireDaemon()
407
442
  try { fs.rmSync(devFile, { force: true }) } catch (_) {}
408
443
  writeReq('dump-request.json', {}) // de qué perfil queremos los dispositivos
409
- avisar(s.pid, 'SIGUSR2')
444
+ sendSignal(s.pid, 'SIGUSR2')
410
445
  let snap = null
411
446
  for (let i = 0; i < 50; i++) { await sleep(100); const d = readJson(devFile, null); if (d?.at) { snap = d; break } }
412
447
  if (!snap) { console.error('El daemon no respondió.'); process.exit(1) }
448
+ assertOpen(snap)
413
449
  const revoked = snap.revoked || []
414
- const fuera = new Set(revoked.map((r) => r?.nonce || r))
450
+ const revokedSet = new Set(revoked.map((r) => r?.nonce || r))
415
451
  // UN APARATO, UNA LÍNEA. El daemon lleva la cuenta por CERTIFICADO —correcto para él,
416
452
  // porque revocar es revocar un papel—, pero renovar emite uno nuevo cada 30 días: un
417
453
  // aparato de un año salía doce veces, y los ya retirados seguían contando como
418
454
  // enrolados. Se agrupa por llave y se dice cuántos certificados tiene.
419
- const porLlave = new Map()
455
+ const byKey = new Map()
420
456
  for (const d of (snap.issued || snap.active || snap.delegations || [])) {
421
- if (d.revokedAt || fuera.has(d.nonce)) continue
422
- const clave = d.sub || d.nonce
423
- const y = porLlave.get(clave)
424
- if (!y) porLlave.set(clave, { ...d, certs: 1 })
457
+ if (d.revokedAt || revokedSet.has(d.nonce)) continue
458
+ const key = d.sub || d.nonce
459
+ const y = byKey.get(key)
460
+ if (!y) byKey.set(key, { ...d, certs: 1 })
425
461
  else { y.certs++; if ((d.exp || 0) > (y.exp || 0)) Object.assign(y, { ...d, certs: y.certs }) }
426
462
  }
427
- const active = [...porLlave.values()]
463
+ const active = [...byKey.values()]
428
464
  console.log('Dispositivos enrolados: %d', active.length)
429
465
  for (const d of active) {
430
466
  const did = d.sub ? await deviceIdOf(d.sub) : '????-????'
@@ -454,14 +490,14 @@ async function cmdRevoke (arg) {
454
490
  const esId = /^[0-9a-f]{4}-?[0-9a-f]{4}$/i.test(arg)
455
491
  const s = requireDaemon()
456
492
  if (esId) {
457
- const m = await buscarMiembro(arg.toUpperCase().includes('-') ? arg.toUpperCase() : arg.toUpperCase().replace(/(.{4})(.{4})/, '$1-$2'))
493
+ const m = await findMember(arg.toUpperCase().includes('-') ? arg.toUpperCase() : arg.toUpperCase().replace(/(.{4})(.{4})/, '$1-$2'))
458
494
  writeReq('revoke-request.json', { sub: m.pub })
459
- avisar(s.pid, 'SIGUSR2')
495
+ sendSignal(s.pid, 'SIGUSR2')
460
496
  console.log('Quitado %s (todos sus certificados). Se autoborrará al reconectar. Verifica: dotrino-vault devices', m.id)
461
497
  return
462
498
  }
463
499
  writeReq('revoke-request.json', { nonce: arg })
464
- avisar(s.pid, 'SIGUSR2')
500
+ sendSignal(s.pid, 'SIGUSR2')
465
501
  console.log('Revocación enviada para nonce=%s. El dispositivo se autoborrará al reconectar. Verifica: dotrino-vault devices', arg)
466
502
  }
467
503
 
@@ -478,6 +514,10 @@ function profileDir () {
478
514
  ? list.find((x) => x.id === PROFILE || (x.name || '').toLowerCase() === ref)
479
515
  : (list.find((x) => x.current) || list[0])
480
516
  if (!p) { console.error('el perfil no existe: %s', PROFILE); process.exit(1) }
517
+ // El candado también aquí: esto es la única puerta que lee el directorio del perfil sin
518
+ // pasar por el daemon (la bitácora), y sin esta línea `activity` seguía contando quién
519
+ // firmó y cuándo con la bóveda cerrada.
520
+ assertOpen(p)
481
521
  return path.join(dir, 'p', p.id)
482
522
  }
483
523
 
@@ -500,43 +540,265 @@ function cmdActivity (n = 30) {
500
540
  }
501
541
  }
502
542
 
503
- // Secretos de servicios: se cargan aquí (el dueño, en el PC del vault) y los
504
- // leen los SERVICIOS enrolados con `pair --service <ns>`. Nunca se listan valores.
543
+ // Variables de entorno de los servicios: se cargan aquí (el dueño, en el PC del vault) y
544
+ // las leen los SERVICIOS enrolados con `pair --service <ns>`. Nunca se listan valores.
545
+ //
546
+ // DOS CAJONES: las del SCOPE (`secret set <ns> …`) las comparten todos los aparatos que
547
+ // sirven ese namespace; las del APARATO (`secret device set <ID> …`) las lee solo ese
548
+ // aparato y PISAN a las del scope con el mismo nombre. Ahí va lo que cambia de máquina a
549
+ // máquina (el puerto, la URL pública) sin tener que partir el ns en uno por servidor.
550
+ /**
551
+ * `KEY=valor KEY2=valor2` — la forma de CARGAR VARIAS de una vez. Devuelve `null` si no
552
+ * son todos pares, para que la forma clásica de tres argumentos (`set ns CLAVE valor`,
553
+ * donde el valor puede llevar espacios y hasta un `=`) siga funcionando igual.
554
+ */
555
+ function asPairs (args) {
556
+ if (!args.length) return null
557
+ const out = []
558
+ for (const a of args) {
559
+ const m = PAIR_RE.exec(a)
560
+ if (!m) return null
561
+ out.push({ op: 'set', key: m[1], value: m[2] })
562
+ }
563
+ return out
564
+ }
565
+
566
+ /** Un `.env` con un problema no se carga A MEDIAS: se dice qué línea y no se escribe nada. */
567
+ function abortEnv (errors) {
568
+ console.error('%sNo se cargó nada%s:', R, Z)
569
+ for (const e of errors) console.error(' · %s', envErrorText(e))
570
+ process.exit(2)
571
+ }
572
+
573
+ /** Los códigos del lector de `.env`, en la lengua del CLI. */
574
+ function envErrorText (e) {
575
+ if (e.code === 'shape') return `línea ${e.line}: no tiene la forma CLAVE=valor`
576
+ if (e.code === 'key') return `línea ${e.line}: «${e.key}» va en MAYÚSCULAS_CON_GUION_BAJO`
577
+ if (e.code === 'novalue') return `línea ${e.line}: ${e.key} no tiene valor (para quitarla: secret rm)`
578
+ if (e.code === 'dup') return `línea ${e.line}: ${e.key} ya venía en la línea ${e.first}`
579
+ return 'no hay ninguna variable que cargar'
580
+ }
581
+
582
+ /** El problema de una variable, en español, o `null`. Las reglas son las del cajón. */
583
+ function problemWith (key, value) {
584
+ try { assertVar(key, value); return null } catch (e) {
585
+ if (/invalid key/.test(e.message)) return 'el nombre va en MAYÚSCULAS_CON_GUION_BAJO (p. ej. TURN_KEY_ID)'
586
+ if (/non-empty/.test(e.message)) return 'no tiene valor (para quitarla: secret rm)'
587
+ if (/too long/.test(e.message)) return 'el valor es demasiado largo'
588
+ return e.message
589
+ }
590
+ }
591
+
505
592
  async function cmdSecret (rest) {
506
- const [sub, ns, key, ...valueParts] = rest
593
+ // --public / --private: si el VALOR puede salir de esta máquina hacia la consola remota.
594
+ // Se sacan de la línea antes de partirla, para que puedan ir en cualquier posición y no
595
+ // se cuelen dentro del valor (que es lo último y puede llevar espacios).
596
+ let isPublic
597
+ const rest2 = rest.filter((a) => {
598
+ if (a === '--public') { isPublic = true; return false }
599
+ if (a === '--private') { isPublic = false; return false }
600
+ return true
601
+ })
602
+ const [sub, ...args] = rest2
507
603
  const s = requireDaemon()
508
604
  const secretsListFile = path.join(dir, 'secrets-list.json')
509
605
  const signalAndWaitList = async () => {
510
606
  try { fs.rmSync(secretsListFile, { force: true }) } catch (_) {}
511
607
  writeReq('dump-request.json', {}) // de qué perfil son los secretos
512
- avisar(s.pid, 'SIGUSR2')
513
- for (let i = 0; i < 50; i++) { await sleep(100); const d = readJson(secretsListFile, null); if (d?.at) return d }
608
+ sendSignal(s.pid, 'SIGUSR2')
609
+ for (let i = 0; i < 50; i++) {
610
+ await sleep(100)
611
+ const d = readJson(secretsListFile, null)
612
+ // El volcado lleva el valor de las públicas: se borra en cuanto se tiene, para que no
613
+ // se quede esperando en el disco a que copien la carpeta (ver `daemon.js`).
614
+ if (d?.at) { try { fs.rmSync(secretsListFile, { force: true }) } catch (_) {} ; return assertOpen(d) }
615
+ }
514
616
  console.error('El daemon no respondió.'); process.exit(1)
515
617
  }
618
+ const USAGE = [
619
+ 'uso: dotrino-vault secret set <ns> <CLAVE> <valor> [--public|--private]',
620
+ ' (la comparten todos los aparatos del ns)',
621
+ ' dotrino-vault secret set <ns> CLAVE=valor [CLAVE2=valor2 …] varias DE UNA VEZ',
622
+ ' dotrino-vault secret import <ns> [archivo.env] desde un .env (o stdin)',
623
+ ' dotrino-vault secret rm <ns> <CLAVE>',
624
+ ' dotrino-vault secret device set <ID> <CLAVE> <valor> [--public|--private]',
625
+ ' (solo la lee ese aparato, y pisa a la del ns)',
626
+ ' dotrino-vault secret device set <ID> CLAVE=valor [CLAVE2=valor2 …]',
627
+ ' dotrino-vault secret device import <ID> [archivo.env]',
628
+ ' dotrino-vault secret device rm <ID> <CLAVE>',
629
+ ' dotrino-vault secret list',
630
+ '',
631
+ 'CARGA LA CONFIGURACIÓN DE UN SERVICIO DE UNA VEZ (`set` con varios pares, o `import`):',
632
+ 'la bóveda la aplica entera y avisa UNA sola vez. De una en una, cada variable es un',
633
+ 'cambio de configuración y el servicio se reinicia a media carga.',
634
+ '',
635
+ 'Pública o privada dice UNA cosa: si el VALOR puede salir de esta máquina hacia la',
636
+ 'consola remota (vault.dotrino.com). Se nace privada. El servicio recibe las dos igual.'
637
+ ].join('\n')
638
+
639
+ /**
640
+ * Manda un grupo entero al daemon y comprueba que llegó completo.
641
+ *
642
+ * Se valida TODO aquí antes de escribir nada: si una variable del archivo está mal, no
643
+ * se carga ninguna. Media configuración aplicada es peor que ninguna, porque el
644
+ * servicio arranca con ella y parece que funcionó.
645
+ */
646
+ const sendBatch = async ({ ns = null, pub = null, items, where }) => {
647
+ const bad = items.map((it) => [it.key, problemWith(it.key, it.value)]).filter(([, p]) => p)
648
+ if (bad.length) {
649
+ console.error('%sNo se cargó nada%s. Revisa:', R, Z)
650
+ for (const [key, p] of bad) console.error(' · %s: %s', key, p)
651
+ process.exit(2)
652
+ }
653
+ const withVisibility = items.map((it) => (isPublic === undefined ? it : { ...it, public: isPublic }))
654
+ writeReq('secret-request.json', pub ? { op: 'batch', pub, items: withVisibility } : { op: 'batch', ns, items: withVisibility })
655
+ const d = await signalAndWaitList()
656
+ const list = pub
657
+ ? ((Array.isArray(d.dev) ? d.dev : []).find((x) => x.pub === pub)?.keys || [])
658
+ : (d.ns?.[ns] || [])
659
+ const missing = items.filter((it) => !has(list, it.key)).map((it) => it.key)
660
+ if (missing.length) {
661
+ console.error('El daemon no guardó: %s (revisa: dotrino-vault logs)', missing.join(', '))
662
+ process.exit(1)
663
+ }
664
+ console.log('%d variables guardadas en %s%s%s%s', items.length, B, where, Z,
665
+ isPublic === undefined ? '' : isPublic ? ' (públicas)' : ' (privadas)')
666
+ console.log('Un solo aviso de cambio: el servicio se reinicia una vez, con todo puesto.')
667
+ }
668
+
669
+ /** El texto del `.env`: de un archivo, o de la entrada estándar si no se da ninguno. */
670
+ const readEnvText = (file) => {
671
+ if (file) {
672
+ try { return fs.readFileSync(file, 'utf8') } catch (e) {
673
+ console.error('No se pudo leer %s: %s', file, e.message); process.exit(1)
674
+ }
675
+ }
676
+ if (process.stdin.isTTY) {
677
+ console.error('%s\n\nimport necesita un archivo, o el .env por la entrada estándar:', USAGE)
678
+ console.error(' dotrino-vault secret import proxy .env')
679
+ console.error(' cat .env | dotrino-vault secret import proxy')
680
+ process.exit(2)
681
+ }
682
+ return fs.readFileSync(0, 'utf8')
683
+ }
684
+
685
+ /**
686
+ * Una variable en la lista: su nombre y su valor. La PÚBLICA enseña el suyo (pública
687
+ * quiere decir que ese valor puede salir de esta máquina: taparlo aquí, delante de su
688
+ * dueño, era lo único que la marca no significaba). La privada no se muestra.
689
+ */
690
+ const printVar = (k) => console.log(' · %s %s', k.key, k.public ? `${k.value ?? ''} (pública)` : '••••••')
691
+ const has = (list, key) => (list || []).some((x) => x.key === key)
692
+
516
693
  if (sub === 'list') {
517
694
  const d = await signalAndWaitList()
518
695
  const names = d.ns || {}
519
696
  const nss = Object.keys(names)
520
- if (!nss.length) { console.log('No hay secretos guardados. Agrega uno: dotrino-vault secret set <ns> <CLAVE> <valor>'); return }
697
+ const dev = Array.isArray(d.dev) ? d.dev : []
698
+ if (!nss.length && !dev.length) {
699
+ console.log('No hay variables guardadas. Agrega una: dotrino-vault secret set <ns> <CLAVE> <valor>')
700
+ return
701
+ }
702
+ if (nss.length) console.log('\n%sPor scope%s (las comparten todos los aparatos del perfil)\n', B, Z)
521
703
  for (const n of nss) {
522
704
  console.log('%s%s%s (scope vault:secrets:%s)', B, n, Z, n)
523
- for (const k of names[n]) console.log(' · %s', k)
705
+ for (const k of names[n]) printVar(k)
706
+ }
707
+ if (dev.length) console.log('\n%sPor aparato%s (solo las lee ese aparato; pisan a las del scope)\n', B, Z)
708
+ for (const x of dev) {
709
+ const who = [x.label, x.cn ? `servicio «${x.cn}»` : null, x.orphan ? 'YA NO ESTÁ EN EL ACTA' : null].filter(Boolean).join(' · ')
710
+ console.log('%s%s%s%s', B, x.id, Z, who ? ' ' + who : '')
711
+ for (const k of x.keys) printVar(k)
524
712
  }
713
+ console.log('\n(pública) = su valor se puede ver desde la consola remota. Las demás no salen de aquí.\n')
525
714
  return
526
715
  }
527
- if (sub === 'set' || sub === 'rm') {
716
+
717
+ // Por APARATO: `secret device set|rm|visibility <ID> <CLAVE> [valor|public|private]`.
718
+ if (sub === 'device') {
719
+ const [op, id, key, ...valueParts] = args
528
720
  const value = valueParts.join(' ')
529
- if (!ns || !key || (sub === 'set' && !value)) {
530
- console.error('uso: dotrino-vault secret set <ns> <CLAVE> <valor>\n dotrino-vault secret rm <ns> <CLAVE>'); process.exit(2)
721
+ const ops = ['set', 'rm', 'visibility', 'import']
722
+ const asGroup = op === 'import' || (op === 'set' && !!asPairs(args.slice(2)))
723
+ if (!ops.includes(op) || !id || (!asGroup && (!key || (op === 'set' && !value)))) { console.error(USAGE); process.exit(2) }
724
+ const m = await findMember(id)
725
+ // Se avisa aquí, con nombre y apellido, en vez de dejar que el daemon lo rechace y la
726
+ // CLI diga «no aplicó el cambio»: quien escribe esto quiere saber POR QUÉ no vale.
727
+ if (!m.cn) {
728
+ console.error('%s no es un servicio, y solo los servicios leen variables.', m.id)
729
+ console.error('Empareja el servicio con: dotrino-vault pair --service <ns>')
730
+ process.exit(1)
531
731
  }
532
- writeReq('secret-request.json', sub === 'set' ? { op: 'set', ns, key, value } : { op: 'rm', ns, key })
732
+ if (op === 'import') {
733
+ const { items, errors } = parseEnvText(readEnvText(args[2]))
734
+ if (errors.length) return abortEnv(errors)
735
+ return sendBatch({ pub: m.pub, items, where: m.id })
736
+ }
737
+ if (asGroup) return sendBatch({ pub: m.pub, items: asPairs(args.slice(2)), where: m.id })
738
+ if (key && PAIR_RE.test(key) && args.length > 3) {
739
+ console.error('%s\n\nO todos los argumentos son CLAVE=valor, o es una sola variable.', USAGE)
740
+ process.exit(2)
741
+ }
742
+ const req = op === 'set'
743
+ ? { op: 'dev-set', pub: m.pub, key, value, ...(isPublic === undefined ? {} : { public: isPublic }) }
744
+ : op === 'rm'
745
+ ? { op: 'dev-rm', pub: m.pub, key }
746
+ : { op: 'dev-vis', pub: m.pub, key, public: wantsPublic(value, USAGE) }
747
+ writeReq('secret-request.json', req)
533
748
  const d = await signalAndWaitList()
534
- const ok = sub === 'set' ? (d.ns?.[ns] || []).includes(key) : !(d.ns?.[ns] || []).includes(key)
535
- if (ok) console.log(sub === 'set' ? 'Secreto guardado: %s/%s' : 'Secreto borrado: %s/%s', ns, key)
536
- else { console.error('El daemon no aplicó el cambio (revisa: dotrino-vault logs)'); process.exit(1) }
749
+ const keys = (Array.isArray(d.dev) ? d.dev : []).find((x) => x.pub === m.pub)?.keys || []
750
+ const ok = op === 'rm' ? !has(keys, key) : has(keys, key)
751
+ if (!ok) { console.error('El daemon no aplicó el cambio (revisa: dotrino-vault logs)'); process.exit(1) }
752
+ if (op === 'rm') console.log('Variable borrada: %s/%s', m.id, key)
753
+ else console.log('Variable guardada: %s/%s%s', m.id, key, (keys.find((x) => x.key === key)?.public) ? ' (pública)' : '')
537
754
  return
538
755
  }
539
- console.error('uso: dotrino-vault secret {set|rm|list}'); process.exit(2)
756
+
757
+ // Desde un `.env`: el caso real de estrenar un servicio, y el que de una en una
758
+ // reiniciaba al agente una vez por variable.
759
+ if (sub === 'import') {
760
+ const [ns, file] = args
761
+ if (!ns) { console.error(USAGE); process.exit(2) }
762
+ const { items, errors } = parseEnvText(readEnvText(file))
763
+ if (errors.length) return abortEnv(errors)
764
+ return sendBatch({ ns, items, where: ns })
765
+ }
766
+
767
+ if (sub === 'set' || sub === 'rm' || sub === 'visibility') {
768
+ const [ns, key, ...valueParts] = args
769
+ const value = valueParts.join(' ')
770
+ // `set <ns> CLAVE=valor CLAVE2=valor2` — varias de una vez, un solo aviso.
771
+ const pairs = sub === 'set' ? asPairs(args.slice(1)) : null
772
+ if (ns && pairs) return sendBatch({ ns, items: pairs, where: ns })
773
+ // Mezclar las dos formas (`K1=v1 CLAVE valor`) no es ninguna de las dos: mejor
774
+ // decirlo que guardar una variable llamada «K1=v1».
775
+ if (sub === 'set' && key && PAIR_RE.test(key) && args.length > 2) {
776
+ console.error('%s\n\nO todos los argumentos son CLAVE=valor, o es una sola variable: set <ns> <CLAVE> <valor>.', USAGE)
777
+ process.exit(2)
778
+ }
779
+ if (!ns || !key || (sub === 'set' && !value)) { console.error(USAGE); process.exit(2) }
780
+ const req = sub === 'set'
781
+ ? { op: 'set', ns, key, value, ...(isPublic === undefined ? {} : { public: isPublic }) }
782
+ : sub === 'rm'
783
+ ? { op: 'rm', ns, key }
784
+ : { op: 'vis', ns, key, public: wantsPublic(value, USAGE) }
785
+ writeReq('secret-request.json', req)
786
+ const d = await signalAndWaitList()
787
+ const list = d.ns?.[ns] || []
788
+ const ok = sub === 'rm' ? !has(list, key) : has(list, key)
789
+ if (!ok) { console.error('El daemon no aplicó el cambio (revisa: dotrino-vault logs)'); process.exit(1) }
790
+ if (sub === 'rm') console.log('Secreto borrado: %s/%s', ns, key)
791
+ else console.log('Secreto guardado: %s/%s%s', ns, key, (list.find((x) => x.key === key)?.public) ? ' (pública)' : '')
792
+ return
793
+ }
794
+ console.error(USAGE); process.exit(2)
795
+ }
796
+
797
+ /** `visibility … public|private` — el único argumento que acepta, y sin adivinar. */
798
+ function wantsPublic (word, usage) {
799
+ if (word === 'public') return true
800
+ if (word === 'private') return false
801
+ console.error('%s\n\nvisibility acepta «public» o «private».', usage); process.exit(2)
540
802
  }
541
803
 
542
804
  /**
@@ -546,7 +808,7 @@ async function cmdSecret (rest) {
546
808
  function askPassword (prompt) {
547
809
  return new Promise((resolve, reject) => {
548
810
  const stdin = process.stdin
549
- if (!stdin.isTTY) return reject(new Error('hace falta un terminal para escribir la contraseña'))
811
+ if (!stdin.isTTY) return reject(new Error('a terminal is required to type the password'))
550
812
  process.stdout.write(prompt)
551
813
  stdin.setRawMode(true); stdin.resume(); stdin.setEncoding('utf8')
552
814
  let buf = ''
@@ -558,7 +820,7 @@ function askPassword (prompt) {
558
820
  const onData = (ch) => {
559
821
  for (const c of ch) {
560
822
  if (c === '\n' || c === '\r' || c === '\u0004') return done(null, buf) // Enter / Ctrl-D
561
- if (c === '\u0003') return done(new Error('cancelado')) // Ctrl-C
823
+ if (c === '\u0003') return done(new Error('cancelled')) // Ctrl-C
562
824
  if (c === '\u007f' || c === '\b') { buf = buf.slice(0, -1); continue } // borrar
563
825
  buf += c
564
826
  }
@@ -576,7 +838,7 @@ async function profileRequest (op, extra = {}) {
576
838
  const s = requireDaemon()
577
839
  try { fs.rmSync(profilesFile, { force: true }) } catch (_) {}
578
840
  writeReq('profile-request.json', { op, ...extra })
579
- avisar(s.pid, 'SIGUSR2')
841
+ sendSignal(s.pid, 'SIGUSR2')
580
842
  for (let i = 0; i < 100; i++) {
581
843
  await sleep(100)
582
844
  const d = readJson(profilesFile, null)
@@ -586,7 +848,14 @@ async function profileRequest (op, extra = {}) {
586
848
  }
587
849
 
588
850
  function reportProfiles (d) {
589
- if (d.error) { console.error('%s', d.error); process.exit(1) }
851
+ if (d.error) {
852
+ // Los dos rechazos del candado se dicen con palabras y con el dato que hace falta; el
853
+ // resto se reenvía tal cual (son diagnósticos del servicio).
854
+ if (d.code === 'WRONG_PASSWORD') console.error('Contraseña incorrecta%s.', d.tries ? ` — van ${d.tries} intentos fallidos` : '')
855
+ else if (d.code === 'TOO_MANY_TRIES') console.error('Demasiados intentos: espera %s s antes de volver a probar.', d.waitSec || '?')
856
+ else console.error('%s', d.error)
857
+ process.exit(1)
858
+ }
590
859
  if (d.done) console.log('%s', d.done)
591
860
  return d
592
861
  }
@@ -691,9 +960,25 @@ function help () {
691
960
  estrena una cuenta VACÍA en este vault y mete ahí al dispositivo
692
961
  (sin la bandera entra a la cuenta activa, o a la de --profile)
693
962
  pair --service <ns> empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
694
- secret set <ns> <CLAVE> <valor> guarda un secreto para el servicio <ns>
695
- secret rm <ns> <CLAVE> borra un secreto
696
- secret list lista nombres de secretos (nunca valores)
963
+ secret set <ns> <CLAVE> <valor> variable del scope <ns>: la comparten TODOS los
964
+ aparatos del perfil que sirven ese namespace
965
+ secret set <ns> CLAVE=valor CLAVE2=valor2
966
+ carga VARIAS de una vez: se aplican juntas y el
967
+ servicio recibe UN solo aviso (se reinicia una vez)
968
+ secret import <ns> [archivo.env] lo mismo desde un .env (o por la entrada estándar)
969
+ secret rm <ns> <CLAVE> borra una variable del scope
970
+ secret device set <ID> <CLAVE> <valor>
971
+ variable de UN aparato: solo la lee él, y pisa a la
972
+ del scope que se llame igual (puerto, URL pública…)
973
+ secret device set <ID> CLAVE=valor …
974
+ secret device import <ID> [archivo.env]
975
+ secret device rm <ID> <CLAVE> borra una variable de ese aparato
976
+ secret list lista los dos cajones: el valor de las públicas,
977
+ tapadas las privadas
978
+ --public | --private (al hacer un set) si el VALOR puede salir de esta
979
+ máquina hacia la consola remota. Se nace privada.
980
+ secret visibility <ns> <CLAVE> public|private cambia eso sin tocar el valor
981
+ secret device visibility <ID> <CLAVE> public|private
697
982
  pending muestra el dispositivo pendiente + su código a comparar
698
983
  approve <código> aprueba el dispositivo tipeando el código que MUESTRA (el vault no lo sabe)
699
984
  reject <deviceId> rechaza un dispositivo pendiente