@dotrino/vaultd 0.56.0 → 0.59.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/lib/src/enroll.js +7 -2
- package/lib/src/service.js +3 -4
- package/package.json +2 -2
- package/src/client.js +3 -3
- package/src/ctl.js +48 -5
- package/src/daemon.js +12 -1
- package/src/profiles.js +13 -1
- package/src/selfupdate.js +86 -0
- package/src/tui/app.js +36 -5
- package/src/tui/i18n.js +4 -0
- package/src/vaultControl.js +2 -2
package/lib/src/enroll.js
CHANGED
|
@@ -277,7 +277,12 @@ export function createEnrollDesk ({
|
|
|
277
277
|
pend.continuity = (okC && d.continuity.member === d.dpub) ? d.continuity : null
|
|
278
278
|
}
|
|
279
279
|
pend.from = from // la bóveda NO conoce el código: lo aprende cuando lo tipeas
|
|
280
|
-
|
|
280
|
+
// EL NOMBRE QUE PUSISTE AQUÍ MANDA. El aparato manda el suyo al enrolarse, y hasta
|
|
281
|
+
// ahora pisaba siempre al de la bóveda — como el aparato usa por defecto el apodo del
|
|
282
|
+
// PERFIL, acababas con varios dispositivos llamados igual que tú y sin forma de saber
|
|
283
|
+
// cuál era cuál. Si en `pair` le diste un nombre, ese es el nombre; el del aparato
|
|
284
|
+
// sigue valiendo como propuesta cuando no dijiste nada.
|
|
285
|
+
if (d.label && !pend.label) pend.label = String(d.label).slice(0, 60)
|
|
281
286
|
// Camino A: de qué cuenta estamos hablando. Se guarda para poder comprobar, cuando
|
|
282
287
|
// llegue el acta sellada, que es la que este dispositivo dijo que iba a entregar.
|
|
283
288
|
if (intent === 'adopt' && typeof d.profileId === 'string') pend.profileId = d.profileId
|
|
@@ -359,7 +364,7 @@ export function createEnrollDesk ({
|
|
|
359
364
|
pend.state = 'DONE'
|
|
360
365
|
pending.delete(pend.token)
|
|
361
366
|
fire(onPendingChange)
|
|
362
|
-
log(
|
|
367
|
+
log(`[vault] device approved: ${pend.deviceId}`)
|
|
363
368
|
return { ok: true, deviceId: pend.deviceId, cert }
|
|
364
369
|
}
|
|
365
370
|
|
package/lib/src/service.js
CHANGED
|
@@ -23,8 +23,7 @@ import path from 'node:path'
|
|
|
23
23
|
import { createHash } from 'node:crypto'
|
|
24
24
|
import {
|
|
25
25
|
makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDelegation,
|
|
26
|
-
verifyDeviceSig, makePairingCode, commitCode, pubkeyId
|
|
27
|
-
} from '@dotrino/identity/capabilities'
|
|
26
|
+
verifyDeviceSig, makePairingCode, commitCode, pubkeyId, PEER_SKEW_MS } from '@dotrino/identity/capabilities'
|
|
28
27
|
import { openWrap, wrapForMember, decryptWithCek } from '@dotrino/identity/content'
|
|
29
28
|
import { verifyActa, sealKeyAt } from '@dotrino/identity/acta'
|
|
30
29
|
import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
|
|
@@ -298,7 +297,7 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
|
|
|
298
297
|
const res = await enrolled
|
|
299
298
|
|
|
300
299
|
if (res.code !== code) throw new Error('the vault echoed a code other than the one shown (possible malicious relay)')
|
|
301
|
-
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, ...(expectedScope ? { expectedScope } : {}) })
|
|
300
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS, ...(expectedScope ? { expectedScope } : {}) })
|
|
302
301
|
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
303
302
|
if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one in the QR')
|
|
304
303
|
|
|
@@ -440,7 +439,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
440
439
|
client.sendByPubkey(masterPubkey, { type: MSG.RENEW, data, signature, cert })
|
|
441
440
|
const res = await pending
|
|
442
441
|
if (res.type === MSG.RENEWED && res.cert?.sub === device.publickey) {
|
|
443
|
-
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, expectedScope: secretsScope(ns) })
|
|
442
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS, expectedScope: secretsScope(ns) })
|
|
444
443
|
if (v.ok) { cert = res.cert; if (dir && saved) writeServiceIdentity(dir, { ...saved, cert }) }
|
|
445
444
|
}
|
|
446
445
|
} catch (_) { /* la renovación no bloquea el fetch */ }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.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": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"node": ">=20"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@dotrino/identity": "^0.
|
|
23
|
+
"@dotrino/identity": "^0.64.0",
|
|
24
24
|
"@dotrino/passmanager": "^0.9.0",
|
|
25
25
|
"@dotrino/proxy-client": "^0.13.1",
|
|
26
26
|
"ws": "^8.18.0"
|
package/src/client.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* estrictamente (firmado por la maestra que vio en el QR, y para SU clave) antes de
|
|
12
12
|
* guardarlo. La maestra nunca sale del vault.
|
|
13
13
|
*/
|
|
14
|
-
import { makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
|
|
14
|
+
import { makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId, PEER_SKEW_MS } from '@dotrino/identity/capabilities'
|
|
15
15
|
import { installNodeGlobals } from './node-globals.js'
|
|
16
16
|
import { MSG } from './protocol.js'
|
|
17
17
|
|
|
@@ -84,7 +84,7 @@ export async function enroll ({ qr, label = '', dir, onChallenge, approveTimeout
|
|
|
84
84
|
const res = await enrolled
|
|
85
85
|
|
|
86
86
|
// VALIDACIÓN ESTRICTA antes de persistir (cierra inyección de cert / sustitución de maestra).
|
|
87
|
-
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey })
|
|
87
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS })
|
|
88
88
|
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
89
89
|
if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one you saw (possible malicious proxy)')
|
|
90
90
|
if (res.cert.sub !== device.publickey) throw new Error('cert issued for a different device')
|
|
@@ -151,7 +151,7 @@ export async function requestRenew ({ masterPubkey, proxyUrl, device, cert, dir
|
|
|
151
151
|
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
152
152
|
// Se valida antes de devolverlo, igual que en el enrolamiento: un cert que no está
|
|
153
153
|
// firmado por la maestra que ya conocemos, o que no es para esta llave, no se guarda.
|
|
154
|
-
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey })
|
|
154
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS })
|
|
155
155
|
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
156
156
|
if (res.cert.iss !== masterPubkey) throw new Error('cert signed by a master other than the pinned one')
|
|
157
157
|
return { cert: res.cert }
|
package/src/ctl.js
CHANGED
|
@@ -68,6 +68,20 @@ const writeReq = (name, obj) => {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
const R = '\x1b[31m', B = '\x1b[1m', Z = '\x1b[0m' // rojo / negrita / reset
|
|
71
|
+
const D = '\x1b[2m' // apagado: para el dato que acompaña sin competir con el nombre
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* La fecha en que entró un aparato, corta y en local. Sin hora: lo que se busca al mirar
|
|
75
|
+
* esta lista es «¿de cuándo es este?», no el minuto exacto — y una fecha larga en cada
|
|
76
|
+
* línea convierte la lista en un muro.
|
|
77
|
+
*
|
|
78
|
+
* En español, como el resto de esta CLI: con el idioma del sistema salía «Jul 29, 2026»
|
|
79
|
+
* en medio de una frase en español, que es peor que cualquiera de los dos por separado.
|
|
80
|
+
*/
|
|
81
|
+
function fechaCorta (ms) {
|
|
82
|
+
try { return new Date(ms).toLocaleDateString('es', { year: 'numeric', month: 'short', day: 'numeric' }) }
|
|
83
|
+
catch (_) { return '' }
|
|
84
|
+
}
|
|
71
85
|
// La versión se inyecta en build (esbuild --define); en dev cae a 'dev'.
|
|
72
86
|
|
|
73
87
|
/**
|
|
@@ -191,6 +205,17 @@ async function cmdPair (args = []) {
|
|
|
191
205
|
// --approval: el aparato que entre pedirá tu aprobación (teléfono) en cada petición de
|
|
192
206
|
// claves privadas. Por defecto NO pide; se cambia después con `caps <ID> +permiso`.
|
|
193
207
|
const approval = args.includes('--approval')
|
|
208
|
+
// --name <n>: CÓMO SE VA A LLAMAR el aparato que entre, decidido aquí y antes de nada.
|
|
209
|
+
// Sin esto el nombre lo ponía el propio aparato, que por defecto usa el apodo del
|
|
210
|
+
// PERFIL: acababas con varios dispositivos llamados igual que tú, y para distinguirlos
|
|
211
|
+
// había que renombrarlos después —cuando ya no sabías cuál era cuál—.
|
|
212
|
+
const nIdx = args.indexOf('--name')
|
|
213
|
+
let label = null
|
|
214
|
+
if (nIdx >= 0) {
|
|
215
|
+
const v = args[nIdx + 1]
|
|
216
|
+
if (!v || v.startsWith('-')) { console.error('uso: dotrino-vault pair --name "teléfono de casa"'); process.exit(2) }
|
|
217
|
+
label = v.slice(0, 60)
|
|
218
|
+
}
|
|
194
219
|
// --admin: el aparato que entre por esta invitación podrá ADMINISTRAR (la consola).
|
|
195
220
|
// El QR no lleva nada: es una nota local de la bóveda y el permiso se aplica al aprobar
|
|
196
221
|
// con el código que tecleas aquí. Ahorra el `caps <ID> +administra` de después, que en un
|
|
@@ -244,9 +269,11 @@ async function cmdPair (args = []) {
|
|
|
244
269
|
// meter el dispositivo en una cuenta que ya vive aquí, se ESTRENA una (vacía) y
|
|
245
270
|
// entra a ella; las demás no se tocan. En la TUI esto es una pregunta con sus
|
|
246
271
|
// opciones; en la CLI es una bandera, para que siga sirviendo en un script.
|
|
272
|
+
// `--name` lleva su valor detrás: no puede confundirse con el nombre de la cuenta.
|
|
273
|
+
const consumido = (i) => nIdx >= 0 && i === nIdx + 1
|
|
247
274
|
const naIdx = args.findIndex((a) => a === '--new-account')
|
|
248
275
|
if (naIdx >= 0) {
|
|
249
|
-
const next = args[naIdx + 1]
|
|
276
|
+
const next = consumido(naIdx + 1) ? null : args[naIdx + 1]
|
|
250
277
|
const name = (next && !next.startsWith('-')) ? next : `cuenta ${new Date().toISOString().slice(0, 10)}`
|
|
251
278
|
const d = await profileRequest('add', { name, ...(pairKek ? { kek: pairKek } : {}) })
|
|
252
279
|
if (d.error) { console.error('%s', d.error); process.exit(1) }
|
|
@@ -261,7 +288,7 @@ async function cmdPair (args = []) {
|
|
|
261
288
|
const adIdx = args.findIndex((a) => a === '--adopt')
|
|
262
289
|
const adopt = adIdx >= 0
|
|
263
290
|
if (adopt) {
|
|
264
|
-
const next = args[adIdx + 1]
|
|
291
|
+
const next = consumido(adIdx + 1) ? null : args[adIdx + 1]
|
|
265
292
|
const name = (next && !next.startsWith('-')) ? next : `cuenta del dispositivo`
|
|
266
293
|
const d = await profileRequest('add', { name, adopt: true, ...(pairKek ? { kek: pairKek } : {}) })
|
|
267
294
|
if (d.error) { console.error('%s', d.error); process.exit(1) }
|
|
@@ -284,7 +311,7 @@ async function cmdPair (args = []) {
|
|
|
284
311
|
|
|
285
312
|
// La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
|
|
286
313
|
// se empareja el dispositivo.
|
|
287
|
-
writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(approval ? { approval: true } : {}), ...(admin ? { admin: true } : {}) })
|
|
314
|
+
writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(approval ? { approval: true } : {}), ...(admin ? { admin: true } : {}), ...(label ? { label } : {}) })
|
|
288
315
|
sendSignal(s.pid, 'SIGUSR1')
|
|
289
316
|
|
|
290
317
|
let pair = null
|
|
@@ -505,7 +532,14 @@ async function cmdMembers () {
|
|
|
505
532
|
assertOpen(record)
|
|
506
533
|
if (!record.members?.length) { console.log('Este perfil todavía no tiene acta.'); return }
|
|
507
534
|
|
|
508
|
-
|
|
535
|
+
// `sealer` y `passwords` faltaban y salían crudos, en inglés, entre los demás en
|
|
536
|
+
// español. Se resaltan como `admin` y `approve`: los cuatro cambian lo que ese aparato
|
|
537
|
+
// puede hacerle a la cuenta, y eso se lee de un vistazo o no se lee.
|
|
538
|
+
const CAP = {
|
|
539
|
+
sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves',
|
|
540
|
+
admin: `${B}administra el perfil${Z}`, approve: `${B}aprueba pedidos${Z}`,
|
|
541
|
+
sealer: `${B}sella el acta${Z}`, passwords: `${B}pide contraseñas${Z}`
|
|
542
|
+
}
|
|
509
543
|
// El nombre del perfil es una pubkey JWK. Recortarla no la hace legible: la deja
|
|
510
544
|
// pareciendo un error (`{"key_ops":["verify"],"e…`). Se muestra su huella corta, la
|
|
511
545
|
// misma que se enseña al emparejar y en la lista de miembros.
|
|
@@ -519,7 +553,13 @@ async function cmdMembers () {
|
|
|
519
553
|
m.cn ? `servicio «${m.cn}»` : null
|
|
520
554
|
].filter(Boolean)
|
|
521
555
|
const caps = m.caps.length ? m.caps.map((c) => CAP[c] || c).join(', ') : '(sin permisos)'
|
|
522
|
-
|
|
556
|
+
// CUÁNDO ENTRÓ. El nombre lo pone el propio aparato al emparejarse y muchas veces no
|
|
557
|
+
// distingue nada —dos teléfonos con el mismo apodo, o «cli» a secas—, así que la fecha
|
|
558
|
+
// es lo que deja reconocer cuál es cuál y ver si hay uno que no recuerdas haber
|
|
559
|
+
// conectado. El acta ya la guardaba (`addedAt`); solo no se enseñaba.
|
|
560
|
+
console.log(' %s %s%s%s\n %s', m.id, who,
|
|
561
|
+
m.addedAt ? ` ${D}· conectado el ${fechaCorta(m.addedAt)}${Z}` : '',
|
|
562
|
+
marks.length ? ' [' + marks.join(' · ') + ']' : '', caps)
|
|
523
563
|
// Un servicio SIN llave de cifrado no puede leer ninguna variable privada: van
|
|
524
564
|
// selladas a esa llave. Se dice aquí, junto a él, porque es el único sitio donde
|
|
525
565
|
// se mira quién es quién — y en la lista de variables ya seria tarde.
|
|
@@ -1442,6 +1482,9 @@ function help () {
|
|
|
1442
1482
|
estrena una cuenta VACÍA en este vault y mete ahí al dispositivo
|
|
1443
1483
|
(sin la bandera entra a la cuenta activa, o a la de --profile)
|
|
1444
1484
|
pair --service <ns> empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
|
|
1485
|
+
pair --name <nombre> cómo se llamará el aparato que entre. Sin esto el nombre lo pone
|
|
1486
|
+
ÉL, y por defecto usa el apodo del perfil: acabas con varios
|
|
1487
|
+
dispositivos llamados igual que tú
|
|
1445
1488
|
pair --approval el aparato que entre pedirá tu aprobación (teléfono) al recibir claves
|
|
1446
1489
|
pair --admin el aparato que entre podrá ADMINISTRAR (es lo que es una consola).
|
|
1447
1490
|
El QR no lleva nada: el permiso se aplica al aprobar el código aquí
|
package/src/daemon.js
CHANGED
|
@@ -20,6 +20,7 @@ import { takeLock } from '../lib/src/lock.js'
|
|
|
20
20
|
import { startVaultManager } from './manager.js'
|
|
21
21
|
import { dataDir, writeJson, readJson } from './paths.js'
|
|
22
22
|
import { parseInvite } from '../lib/src/invite.js'
|
|
23
|
+
import { watchBinary } from './selfupdate.js'
|
|
23
24
|
import { VERSION } from './version.js'
|
|
24
25
|
|
|
25
26
|
const readJsonSafe = (f) => readJson(f, null)
|
|
@@ -742,7 +743,7 @@ export async function runDaemon () {
|
|
|
742
743
|
|
|
743
744
|
// --- apagado limpio ---
|
|
744
745
|
const shutdown = (sig) => {
|
|
745
|
-
console.log(`\n[vault] ${sig} →
|
|
746
|
+
console.log(`\n[vault] ${sig} → stopping…`)
|
|
746
747
|
rm(pairFile); rm(pendingEnrollFile)
|
|
747
748
|
try { mgr.close() } catch (_) {}
|
|
748
749
|
process.exit(0)
|
|
@@ -803,6 +804,16 @@ export async function runDaemon () {
|
|
|
803
804
|
}
|
|
804
805
|
bootstrapJoin()
|
|
805
806
|
|
|
807
|
+
// ACTUALIZAR EL PAQUETE = ACTUALIZAR EL SERVICIO. El instalador no puede reiniciarnos
|
|
808
|
+
// (somos un servicio de USUARIO y él corre como root), así que lo hacemos nosotros: si
|
|
809
|
+
// el binario cambia en el disco, nos vamos y systemd nos levanta con el nuevo. Solo bajo
|
|
810
|
+
// systemd; ver `src/selfupdate.js` para por qué eso no es negociable.
|
|
811
|
+
const vigia = watchBinary({
|
|
812
|
+
log: console.log,
|
|
813
|
+
exit: () => shutdown('new binary installed')
|
|
814
|
+
})
|
|
815
|
+
if (vigia) console.log('[vault] watching for updates: a new binary restarts the service on its own')
|
|
816
|
+
|
|
806
817
|
console.log('[vault] servicio listo.')
|
|
807
818
|
return mgr
|
|
808
819
|
}
|
package/src/profiles.js
CHANGED
|
@@ -222,6 +222,10 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
222
222
|
// la llave— es largo, y nadie va a teclear 24 caracteres para decir `--profile`. El
|
|
223
223
|
// trozo que sí se lee y se reconoce es la huella de delante (`0571-465F`), que es la
|
|
224
224
|
// misma que sale en `members`. Ambiguo se rechaza, no se adivina.
|
|
225
|
+
// El id que tenía antes de que la carpeta se llamara como su llave (ver
|
|
226
|
+
// `ensureNamedByKey`): un cliente abierto durante la actualización sigue usándolo.
|
|
227
|
+
const viejo = data.profiles.find((p) => p.wasId && p.wasId.toLowerCase() === needle)
|
|
228
|
+
if (viejo) return viejo.id
|
|
225
229
|
const porPrefijo = data.profiles.filter((p) => p.id.toLowerCase().startsWith(needle))
|
|
226
230
|
if (porPrefijo.length === 1) return porPrefijo[0].id
|
|
227
231
|
if (porPrefijo.length > 1) throw new Error(`"${ref}" matches ${porPrefijo.length} profiles; give more of the id (dotrino-vault profile ls)`)
|
|
@@ -380,7 +384,15 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
380
384
|
}
|
|
381
385
|
fs.renameSync(dir, dirOf(quiere))
|
|
382
386
|
const p = find(id)
|
|
383
|
-
if (p)
|
|
387
|
+
if (p) {
|
|
388
|
+
p.id = quiere
|
|
389
|
+
// EL NOMBRE VIEJO SE RECUERDA. Quien tenía una consola o una TUI abierta durante
|
|
390
|
+
// la actualización sigue pidiendo por el id de antes, y sin esto cada petición
|
|
391
|
+
// suya contestaba «ese perfil no existe» —que es verdad y no ayuda nada—. Se
|
|
392
|
+
// traduce en `resolve`, así que el cliente viejo sigue funcionando hasta que se
|
|
393
|
+
// reinicie, que es cuando dejará de preguntarlo.
|
|
394
|
+
p.wasId = id
|
|
395
|
+
}
|
|
384
396
|
if (data.current === id) data.current = quiere
|
|
385
397
|
if (unlocked.has(id)) { unlocked.set(quiere, unlocked.get(id)); unlocked.delete(id) }
|
|
386
398
|
save()
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* selfupdate.js — ACTUALIZAR EL PAQUETE TENÍA QUE SIGNIFICAR ACTUALIZAR EL SERVICIO.
|
|
3
|
+
*
|
|
4
|
+
* No lo significaba, y el resultado era una bóveda que decía correr una versión y corría
|
|
5
|
+
* otra. Pasó de verdad (dueño, 2026-08-31): el `.deb` puso 0.56.0 en el disco y el daemon
|
|
6
|
+
* siguió ocho versiones atrás durante días, sin que nada fallara de forma visible —
|
|
7
|
+
* `status` lo avisaba en una línea que nadie mira, y el aviso del instalador se pierde
|
|
8
|
+
* entre el resto de la salida.
|
|
9
|
+
*
|
|
10
|
+
* POR QUÉ EL INSTALADOR NO PUEDE HACERLO. La unidad es de USUARIO (`systemctl --user`) y
|
|
11
|
+
* el `postinst` del paquete corre como root. Root no tiene la sesión del usuario: para
|
|
12
|
+
* reiniciarle el servicio tendría que averiguar qué usuarios lo tienen puesto, buscar su
|
|
13
|
+
* `XDG_RUNTIME_DIR` y entrar en su bus. Es frágil, y con varios usuarios en la máquina es
|
|
14
|
+
* además decidir por ellos. Por eso el instalador solo lo dice.
|
|
15
|
+
*
|
|
16
|
+
* ASÍ QUE LO HACE EL DAEMON. Mira su propio binario y, cuando cambia, se va limpiamente;
|
|
17
|
+
* systemd lo levanta otra vez (`Restart=always`) y esa vez arranca el nuevo. Es la única
|
|
18
|
+
* vía que no necesita root, funciona con cualquier número de usuarios y no depende de que
|
|
19
|
+
* nadie se acuerde de nada.
|
|
20
|
+
*
|
|
21
|
+
* LAS DOS CONDICIONES, y ninguna es opcional:
|
|
22
|
+
*
|
|
23
|
+
* 1. **Que haya quien nos levante.** Si nadie nos reinicia, irse es apagar la bóveda del
|
|
24
|
+
* usuario para siempre — el peor final posible para una mejora de comodidad. Se exige
|
|
25
|
+
* `INVOCATION_ID`, que systemd pone en el entorno del servicio: sin eso, no se toca
|
|
26
|
+
* nada. Un `npx`, un contenedor o un arranque a mano quedan fuera a propósito.
|
|
27
|
+
* 2. **Que el archivo esté quieto.** Un `.deb` a medio escribir cambia de tamaño varias
|
|
28
|
+
* veces; irse en ese instante deja a systemd arrancando un binario truncado. Por eso
|
|
29
|
+
* la huella nueva tiene que verse en DOS pasadas seguidas: si aún se está copiando,
|
|
30
|
+
* la segunda no coincide y se vuelve a esperar.
|
|
31
|
+
*/
|
|
32
|
+
import fs from 'node:fs'
|
|
33
|
+
import path from 'node:path'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Cada cuánto se mira. No hay prisa: un minuto de retraso en actualizar no le duele a
|
|
37
|
+
* nadie, y este mismo plazo hace de espera — la huella nueva tiene que verse DOS veces
|
|
38
|
+
* seguidas, así que un archivo a medio escribir no llega a contar.
|
|
39
|
+
*/
|
|
40
|
+
export const CHECK_MS = 60_000
|
|
41
|
+
|
|
42
|
+
/** Huella barata del archivo: tamaño + fecha. No hace falta leerlo entero (son 125 MB). */
|
|
43
|
+
function fingerprint (file) {
|
|
44
|
+
try { const s = fs.statSync(file); return `${s.size}:${Math.round(s.mtimeMs)}` } catch (_) { return null }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* ¿Estamos corriendo el binario autosuficiente, bajo un systemd que nos va a levantar?
|
|
49
|
+
*
|
|
50
|
+
* `process.execPath` es el binario cuando es un SEA, y es `node` cuando se corre desde el
|
|
51
|
+
* repo o por npx. En ese segundo caso vigilar no tiene sentido: actualizar el paquete no
|
|
52
|
+
* cambia `node`, y lo que habría que mirar es otra cosa.
|
|
53
|
+
*/
|
|
54
|
+
export function shouldWatch (env = process.env, execPath = process.execPath) {
|
|
55
|
+
if (!env.INVOCATION_ID) return false // nadie garantiza el reinicio
|
|
56
|
+
return /dotrino-vaultd/.test(path.basename(execPath)) // el binario, no `node`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Vigila el binario propio y termina el proceso cuando cambie.
|
|
61
|
+
* @returns {{stop:()=>void}|null} `null` si no procede vigilar.
|
|
62
|
+
*/
|
|
63
|
+
export function watchBinary ({ log = console.log, exit = (c) => process.exit(c), env = process.env, execPath = process.execPath, checkMs = CHECK_MS } = {}) {
|
|
64
|
+
if (!shouldWatch(env, execPath)) return null
|
|
65
|
+
const inicial = fingerprint(execPath)
|
|
66
|
+
if (!inicial) return null
|
|
67
|
+
|
|
68
|
+
let quieto = null // huella del candidato, para exigir que se repita
|
|
69
|
+
|
|
70
|
+
const t = setInterval(() => {
|
|
71
|
+
const ahora = fingerprint(execPath)
|
|
72
|
+
if (!ahora || ahora === inicial) { quieto = null; return }
|
|
73
|
+
if (quieto !== ahora) { quieto = ahora; return } // primera vez que se ve: esperar
|
|
74
|
+
clearInterval(t)
|
|
75
|
+
log('[vault] the binary changed on disk: restarting to run the new version')
|
|
76
|
+
// Se sale con 0 y limpiamente: systemd lo trata como una parada normal y vuelve a
|
|
77
|
+
// arrancar por `Restart=always`. El apagado del daemon ya suelta el candado y cierra
|
|
78
|
+
// el transporte, así que la bóveda no queda ni con el candado puesto ni a medias.
|
|
79
|
+
exit(0)
|
|
80
|
+
}, Math.max(1000, checkMs))
|
|
81
|
+
t.unref?.()
|
|
82
|
+
|
|
83
|
+
return { stop: () => clearInterval(t) }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export default { watchBinary, shouldWatch, CHECK_MS }
|
package/src/tui/app.js
CHANGED
|
@@ -918,12 +918,41 @@ async function descartarCuenta (term, st, id, volverA) {
|
|
|
918
918
|
if (r.ok) flash(st, i.accountDiscarded)
|
|
919
919
|
}
|
|
920
920
|
|
|
921
|
+
/**
|
|
922
|
+
* PRIMERO EL NOMBRE, y luego el QR.
|
|
923
|
+
*
|
|
924
|
+
* Sin preguntarlo, el nombre lo pone el propio aparato al enrolarse, y por defecto usa el
|
|
925
|
+
* apodo del PERFIL: te quedaban tres dispositivos llamados igual que tú y ninguna forma de
|
|
926
|
+
* saber cuál era cuál sin renombrarlos después —cuando ya no te acordabas—. Se pregunta
|
|
927
|
+
* ANTES porque después ya estás mirando un QR con el teléfono en la mano.
|
|
928
|
+
*
|
|
929
|
+
* Enter en blanco sigue valiendo: deja que lo diga el aparato, que es el comportamiento de
|
|
930
|
+
* siempre para quien no quiera decidirlo.
|
|
931
|
+
*/
|
|
932
|
+
function beginPairingNamed (term, st, profile, service = null, after = null) {
|
|
933
|
+
const i = L(st)
|
|
934
|
+
setInput(st, {
|
|
935
|
+
label: i.nameDeviceLabel,
|
|
936
|
+
hint: i.nameDeviceHint,
|
|
937
|
+
onSubmit: async (nombre) => {
|
|
938
|
+
st.input = null
|
|
939
|
+
const ok = await beginPairing(term, st, profile, service, (nombre || '').trim().slice(0, 60) || null)
|
|
940
|
+
// `after` es para quien tenga que limpiar si el emparejamiento no llega a abrirse
|
|
941
|
+
// (la cuenta que nació para él). Va aquí porque con el nombre de por medio ya no se
|
|
942
|
+
// puede mirar el valor de retorno desde fuera.
|
|
943
|
+
if (after) await after(ok)
|
|
944
|
+
},
|
|
945
|
+
onCancel: () => { st.input = null }
|
|
946
|
+
})
|
|
947
|
+
return true
|
|
948
|
+
}
|
|
949
|
+
|
|
921
950
|
/**
|
|
922
951
|
* Abre el emparejamiento contra `profile` y salta a la pantalla del QR. Con `service`,
|
|
923
952
|
* el QR es el de un SERVICIO de ese namespace (cert limitado a sus variables).
|
|
924
953
|
*/
|
|
925
|
-
async function beginPairing (term, st, profile, service = null) {
|
|
926
|
-
const r = await guard(term, st, L(st).startingPairing, () => vc.startPairing({ profile, ...(service ? { service } : {}) }))
|
|
954
|
+
async function beginPairing (term, st, profile, service = null, label = null) {
|
|
955
|
+
const r = await guard(term, st, L(st).startingPairing, () => vc.startPairing({ profile, ...(service ? { service } : {}), ...(label ? { label } : {}) }))
|
|
927
956
|
// `service` se pega al estado porque el daemon no lo devuelve: la pantalla del QR
|
|
928
957
|
// tiene que poder decir qué se está entregando, que no es lo mismo un aparato tuyo
|
|
929
958
|
// que una máquina que solo va a leer la configuración del proxy.
|
|
@@ -983,7 +1012,7 @@ async function onKeyPairMode (term, st, key) {
|
|
|
983
1012
|
if (key.name === 'escape' || ch === 'b') { st.screen = 'devices'; return true }
|
|
984
1013
|
if (key.name !== 'enter' || !cur) return true
|
|
985
1014
|
|
|
986
|
-
if (cur.mode === 'here') {
|
|
1015
|
+
if (cur.mode === 'here') { return beginPairingNamed(term, st, activeId(st)) }
|
|
987
1016
|
|
|
988
1017
|
// SERVICIO: entra a la cuenta activa, pero con un certificado que solo sirve para
|
|
989
1018
|
// pedir las variables de SU namespace. El nombre del ns es el que luego pide el
|
|
@@ -1025,8 +1054,10 @@ async function onKeyPairMode (term, st, key) {
|
|
|
1025
1054
|
// Si el emparejamiento no llega a abrirse, la cuenta que se creó PARA él se va con
|
|
1026
1055
|
// él: si no, cada intento fallido dejaba una cuenta vacía —y encima activa— que
|
|
1027
1056
|
// luego había que ir a borrar a mano adivinando cuál era.
|
|
1028
|
-
|
|
1029
|
-
|
|
1057
|
+
beginPairingNamed(term, st, created, null, async (ok) => {
|
|
1058
|
+
if (!ok) await descartarCuenta(term, st, created, previa)
|
|
1059
|
+
else st.pairing.born = { id: created, from: previa }
|
|
1060
|
+
})
|
|
1030
1061
|
},
|
|
1031
1062
|
onCancel: () => { st.input = null }
|
|
1032
1063
|
})
|
package/src/tui/i18n.js
CHANGED
|
@@ -140,6 +140,8 @@ const es = {
|
|
|
140
140
|
cantRemoveMaster: 'Esta bóveda es la que manda: no se quita a sí misma.',
|
|
141
141
|
revokedCount: (n) => ` Revocados: ${n}`,
|
|
142
142
|
startingPairing: 'Iniciando emparejamiento…',
|
|
143
|
+
nameDeviceLabel: '¿Cómo se va a llamar este aparato?',
|
|
144
|
+
nameDeviceHint: 'Enter para dejar que lo diga él (usará tu apodo, y así no se distinguen)',
|
|
143
145
|
noPending: 'No hay ningún dispositivo pendiente',
|
|
144
146
|
noPendingToReject: 'No hay ningún dispositivo pendiente para rechazar',
|
|
145
147
|
rejecting: 'Rechazando…',
|
|
@@ -435,6 +437,8 @@ const en = {
|
|
|
435
437
|
cantRemoveMaster: 'This vault is the Master: it does not remove itself.',
|
|
436
438
|
revokedCount: (n) => ` Revoked: ${n}`,
|
|
437
439
|
startingPairing: 'Starting pairing…',
|
|
440
|
+
nameDeviceLabel: 'What will this device be called?',
|
|
441
|
+
nameDeviceHint: 'Enter to let it say (it will use your nickname, and then they all look alike)',
|
|
438
442
|
noPending: 'No device is waiting',
|
|
439
443
|
noPendingToReject: 'No device is waiting to be rejected',
|
|
440
444
|
rejecting: 'Rejecting…',
|
package/src/vaultControl.js
CHANGED
|
@@ -604,10 +604,10 @@ export function pairUrl (qr) {
|
|
|
604
604
|
return { url: inviteUrl(qr), code, payload: JSON.stringify(qr), b64: code }
|
|
605
605
|
}
|
|
606
606
|
|
|
607
|
-
export async function startPairing ({ profile, service } = {}) {
|
|
607
|
+
export async function startPairing ({ profile, service, label } = {}) {
|
|
608
608
|
requireAlive()
|
|
609
609
|
rm(F.pair); rm(F.pending)
|
|
610
|
-
await writeReq(F.pairReq, service ? { service } : {}, profile)
|
|
610
|
+
await writeReq(F.pairReq, { ...(service ? { service } : {}), ...(label ? { label } : {}) }, profile)
|
|
611
611
|
signalOrCleanup('SIGUSR1', [F.pairReq])
|
|
612
612
|
for (let i = 0; i < 50; i++) {
|
|
613
613
|
await sleep(100)
|