@dotrino/vaultd 0.145.0 → 0.147.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/service.js +113 -0
- package/package.json +1 -1
- package/src/ctl.js +66 -4
- package/src/daemon.js +139 -38
- package/src/notices.js +68 -0
- package/src/notifiable.js +9 -8
- package/src/settings.js +37 -0
- package/src/tui/app.js +69 -5
- package/src/tui/i18n.js +36 -4
- package/src/updateApproval.js +110 -0
- package/src/vault.js +190 -12
package/lib/src/service.js
CHANGED
|
@@ -620,6 +620,119 @@ export async function fetchDigest ({ dir, ns, proxyUrl, masterPubkey, device, ce
|
|
|
620
620
|
} finally { client.close() }
|
|
621
621
|
}
|
|
622
622
|
|
|
623
|
+
/** Cuánto espera un aparato a que alguien apruebe su actualización (el pedido vive un día). */
|
|
624
|
+
export const UPDATE_APPROVAL_TIMEOUT_MS = 24 * 60 * 60 * 1000 + 10 * 1000
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* ¿PUEDO ACTUALIZARME? Lo pregunta un aparato a su bóveda ANTES de instalar una versión
|
|
628
|
+
* nueva, y SOLO si tiene encendido su propio interruptor de «pedir aprobación» (CONVENCIONES
|
|
629
|
+
* §15, dueño 2026-10-08: todo se actualiza solo; pedir aprobación es un ajuste de cada pieza,
|
|
630
|
+
* independiente del de la bóveda). Quien no lo tiene encendido no llama a esto.
|
|
631
|
+
*
|
|
632
|
+
* · nadie aprueba en la cuenta → `{ ok: true, asked: false }`, ya.
|
|
633
|
+
* · hay aprobadores → la bóveda contesta «pendiente», timbra a quien aprueba y el resultado
|
|
634
|
+
* llega por esta misma conexión cuando alguien decide: `{ ok, asked: true }`. El pedido
|
|
635
|
+
* dura un día; vencido es no.
|
|
636
|
+
*
|
|
637
|
+
* LANZA si la bóveda no contesta (`code: 'vault-no-reply'`) o si quedó «pendiente» y el
|
|
638
|
+
* resultado no llegó (`code: 'unanswered'`): quien llama decide qué hacer. Que nadie conteste
|
|
639
|
+
* NUNCA es un sí. Preguntar otra vez por lo mismo mientras el pedido vive no vuelve a timbrar;
|
|
640
|
+
* no repetir la pregunta por la misma versión es cosa de quien llama.
|
|
641
|
+
*
|
|
642
|
+
* @param {object} o
|
|
643
|
+
* @param {string} o.product nombre npm de lo que se actualiza, p. ej. `@dotrino/terminal-agent`
|
|
644
|
+
* @param {string} o.version la versión que se quiere instalar
|
|
645
|
+
* @param {string} [o.from] la que corre ahora
|
|
646
|
+
* @param {string} [o.dir] identidad guardada por `enrollService`; o, en su lugar, la
|
|
647
|
+
* identidad por parámetros (`proxyUrl`, `masterPubkey`, `device`, `cert`), como en los demás.
|
|
648
|
+
* @param {(p: { id: string, exp: number }) => void} [o.onPending]
|
|
649
|
+
* @returns {Promise<{ ok: boolean, asked: boolean }>}
|
|
650
|
+
*/
|
|
651
|
+
export async function askUpdateApproval ({ product, version, from = null, dir, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000, approvalTimeoutMs = UPDATE_APPROVAL_TIMEOUT_MS, onPending } = {}) {
|
|
652
|
+
const saved = dir ? readServiceIdentity(dir) : null
|
|
653
|
+
proxyUrl = proxyUrl || saved?.proxy
|
|
654
|
+
masterPubkey = masterPubkey || saved?.iss
|
|
655
|
+
device = device || saved?.device
|
|
656
|
+
cert = cert || saved?.cert
|
|
657
|
+
if (typeof product !== 'string' || !product || typeof version !== 'string' || !version) throw new Error('askUpdateApproval: needs product and version')
|
|
658
|
+
if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
|
|
659
|
+
const client = await freshClient(proxyUrl, 20000, masterPubkey)
|
|
660
|
+
try {
|
|
661
|
+
await identifyAsService(client, device)
|
|
662
|
+
const data = { op: 'update-ask', product, version, ...(typeof from === 'string' && from ? { from } : {}), publickey: device.publickey, ts: Date.now() }
|
|
663
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
664
|
+
const mine = (p) => p.type === MSG.ERROR || (p.type === MSG.SECRETS_RESULT && p.body?.op === 'update-ask' && p.body.product === product && p.body.version === version)
|
|
665
|
+
const check = async (res) => {
|
|
666
|
+
if (res.type === MSG.ERROR) throw vaultError(res)
|
|
667
|
+
await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
|
|
668
|
+
if (typeof res.body.ts !== 'number' || Math.abs(Date.now() - res.body.ts) > FRESH_WINDOW_MS) throw new Error('stale update-ask reply')
|
|
669
|
+
return res.body
|
|
670
|
+
}
|
|
671
|
+
// UNA COLA, no dos esperas seguidas: el resultado puede llegar mientras todavía se
|
|
672
|
+
// comprueba el «pendiente», y una escucha puesta después lo perdería (y se esperaría un
|
|
673
|
+
// día por algo que ya se contestó).
|
|
674
|
+
const queue = []
|
|
675
|
+
let wake = null
|
|
676
|
+
const off = client.on('message', (_from, p) => { if (p && typeof p === 'object' && mine(p)) { queue.push(p); wake?.() } })
|
|
677
|
+
const next = (ms, code, message) => new Promise((resolve, reject) => {
|
|
678
|
+
if (queue.length) return resolve(queue.shift())
|
|
679
|
+
const t = setTimeout(() => { wake = null; reject(Object.assign(new Error(message), { code })) }, ms)
|
|
680
|
+
wake = () => { clearTimeout(t); wake = null; resolve(queue.shift()) }
|
|
681
|
+
})
|
|
682
|
+
let body
|
|
683
|
+
try {
|
|
684
|
+
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
|
|
685
|
+
body = await check(await next(timeoutMs, 'vault-no-reply', 'update-ask: the vault did not answer'))
|
|
686
|
+
if (body.pending === true) {
|
|
687
|
+
try { onPending?.({ id: body.id, exp: body.exp }) } catch (_) {}
|
|
688
|
+
const until = typeof body.exp === 'number' ? Math.max(5000, body.exp - Date.now() + 5000) : approvalTimeoutMs
|
|
689
|
+
do {
|
|
690
|
+
body = await check(await next(Math.min(until, approvalTimeoutMs), 'unanswered', 'update-ask: nobody answered the request in time'))
|
|
691
|
+
} while (body.pending === true)
|
|
692
|
+
}
|
|
693
|
+
} finally { off() }
|
|
694
|
+
if (typeof body.ok !== 'boolean') throw new Error('malformed update-ask reply')
|
|
695
|
+
return { ok: body.ok, asked: body.asked === true }
|
|
696
|
+
} finally { client.close() }
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* ME ACTUALICÉ: un aparato se lo dice a su bóveda DESPUÉS de instalar, y ella se lo cuenta a
|
|
701
|
+
* quien aprueba en la cuenta (un aviso, y queda 24 h en la lista de avisos). Se llama solo
|
|
702
|
+
* si el aparato tiene encendido su interruptor de avisos de actualización.
|
|
703
|
+
*
|
|
704
|
+
* Quién es el aparato lo pone la bóveda desde el acta, no lo que él diga.
|
|
705
|
+
* LANZA si la bóveda no contesta (`code: 'vault-no-reply'`) o no pudo avisar.
|
|
706
|
+
*
|
|
707
|
+
* @param {object} o `product`, `version`, `from` y la conexión, como en `askUpdateApproval`
|
|
708
|
+
* @returns {Promise<{ ok: true }>}
|
|
709
|
+
*/
|
|
710
|
+
export async function reportUpdated ({ product, version, from = null, dir, proxyUrl, masterPubkey, device, cert, timeoutMs = 30000 } = {}) {
|
|
711
|
+
const saved = dir ? readServiceIdentity(dir) : null
|
|
712
|
+
proxyUrl = proxyUrl || saved?.proxy
|
|
713
|
+
masterPubkey = masterPubkey || saved?.iss
|
|
714
|
+
device = device || saved?.device
|
|
715
|
+
cert = cert || saved?.cert
|
|
716
|
+
if (typeof product !== 'string' || !product || typeof version !== 'string' || !version) throw new Error('reportUpdated: needs product and version')
|
|
717
|
+
if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
|
|
718
|
+
const client = await freshClient(proxyUrl, 20000, masterPubkey)
|
|
719
|
+
try {
|
|
720
|
+
await identifyAsService(client, device)
|
|
721
|
+
const data = { op: 'update-done', product, version, ...(typeof from === 'string' && from ? { from } : {}), publickey: device.publickey, ts: Date.now() }
|
|
722
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
723
|
+
const pending = waitForMsg(client, (p) => p.type === MSG.ERROR || (p.type === MSG.SECRETS_RESULT && p.body?.op === 'update-done' && p.body.product === product && p.body.version === version), timeoutMs)
|
|
724
|
+
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
|
|
725
|
+
const res = await pending.catch((e) => {
|
|
726
|
+
if (/timeout/.test(e.message)) throw Object.assign(new Error('update-done: the vault did not answer'), { code: 'vault-no-reply' })
|
|
727
|
+
throw e
|
|
728
|
+
})
|
|
729
|
+
if (res.type === MSG.ERROR) throw vaultError(res)
|
|
730
|
+
await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
|
|
731
|
+
if (res.body.ok !== true) throw new Error('malformed update-done reply')
|
|
732
|
+
return { ok: true }
|
|
733
|
+
} finally { client.close() }
|
|
734
|
+
}
|
|
735
|
+
|
|
623
736
|
/**
|
|
624
737
|
* Pide los secretos del ns al vault (una petición puntual; lanza si falla).
|
|
625
738
|
* Usa la identidad persistida por `enrollService` salvo que se pase explícita.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.147.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
|
@@ -79,6 +79,10 @@ const D = '\x1b[2m' // apagado: para el dato que acompaña sin competir con el n
|
|
|
79
79
|
* En español, como el resto de esta CLI: con el idioma del sistema salía «Jul 29, 2026»
|
|
80
80
|
* en medio de una frase en español, que es peor que cualquiera de los dos por separado.
|
|
81
81
|
*/
|
|
82
|
+
function fechaHora (ms) {
|
|
83
|
+
try { return new Date(ms).toLocaleString('es', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) }
|
|
84
|
+
catch (_) { return '' }
|
|
85
|
+
}
|
|
82
86
|
function fechaCorta (ms) {
|
|
83
87
|
try { return new Date(ms).toLocaleDateString('es', { year: 'numeric', month: 'short', day: 'numeric' }) }
|
|
84
88
|
catch (_) { return '' }
|
|
@@ -131,6 +135,11 @@ function deviceIdOf (sub) {
|
|
|
131
135
|
return pubkeyId(sub).then((id) => id.slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2'))
|
|
132
136
|
}
|
|
133
137
|
|
|
138
|
+
/** Lo que dijo un aprobador sobre sus avisos, para enseñarlo. No decide nada. */
|
|
139
|
+
function avisosTxt (notifiedAt) {
|
|
140
|
+
return notifiedAt ? `dijo que le llegan los avisos (${fechaCorta(notifiedAt)})` : 'no ha dicho que le lleguen los avisos'
|
|
141
|
+
}
|
|
142
|
+
|
|
134
143
|
function cmdStatus () {
|
|
135
144
|
const s = readState()
|
|
136
145
|
const up = alive(s.pid)
|
|
@@ -153,11 +162,30 @@ function cmdStatus () {
|
|
|
153
162
|
console.log(' %s⬆ hay %s publicada%s (esta es la %s) · tráela con: dotrino-vault update',
|
|
154
163
|
B, s.latest.version, Z, VERSION)
|
|
155
164
|
}
|
|
156
|
-
// …Y
|
|
157
|
-
//
|
|
165
|
+
// …Y SE PIDIÓ PERMISO. El pedido se hace UNA vez por versión y dura un día: mientras
|
|
166
|
+
// espera se dice hasta cuándo; si no se aprobó, que ya no se vuelve a pedir y cómo
|
|
167
|
+
// instalarla. Es un freno que desde fuera no se ve: la bóveda parece normal.
|
|
158
168
|
if (s.updateAsk?.version && isNewer(s.updateAsk.version, VERSION)) {
|
|
159
|
-
|
|
160
|
-
|
|
169
|
+
const u = s.updateAsk
|
|
170
|
+
if (u.result === 'pending') {
|
|
171
|
+
console.log(' se pidió permiso para instalar la %s el %s y está esperando respuesta hasta el %s · mira Pedidos en tu teléfono',
|
|
172
|
+
u.version, fechaHora(u.askedAt), fechaHora(u.askedAt + 24 * 60 * 60 * 1000))
|
|
173
|
+
} else {
|
|
174
|
+
console.log(' se pidió permiso para instalar la %s el %s y no se aprobó: no se vuelve a pedir · instálala con: dotrino-vault update',
|
|
175
|
+
u.version, fechaHora(u.askedAt))
|
|
176
|
+
}
|
|
177
|
+
// A QUIÉN SE LE PIDIÓ, y si dijo que le llegan los avisos: el que no lo ha dicho es el
|
|
178
|
+
// primer sitio donde mirar cuando nadie contesta.
|
|
179
|
+
for (const a of s.updateAsk.approvers || []) {
|
|
180
|
+
console.log(' %s %s%s · %s', a.id, a.label || '(sin nombre)', a.profile ? ` [${a.profile}]` : '', avisosTxt(a.notifiedAt))
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// CÓMO SE ACTUALIZA. Va siempre, también al día: es un ajuste y se mira aquí.
|
|
184
|
+
if (up && 'updateApproval' in s) {
|
|
185
|
+
console.log(' actualizar : %s', s.updateApproval === null
|
|
186
|
+
? `${R}no se pudo leer el ajuste: no se actualiza${Z} · ponlo de nuevo con: dotrino-vault update --approval on|off`
|
|
187
|
+
: s.updateApproval ? 'pide aprobación (dotrino-vault update --approval off para que lo haga sola)'
|
|
188
|
+
: 'sola, sin pedir aprobación (dotrino-vault update --approval on para que pida)')
|
|
161
189
|
}
|
|
162
190
|
const profiles = s.profiles || []
|
|
163
191
|
if (profiles.length) {
|
|
@@ -631,6 +659,9 @@ async function cmdMembers () {
|
|
|
631
659
|
// Solo si LEE variables (`secrets`). La llave de comunicación de la bóveda entra con
|
|
632
660
|
// `cn: 'vault'` y solo `firma`, y no tiene llave de cifrado a propósito: salía marcada
|
|
633
661
|
// en rojo «NO puede leer sus variables» cuando no tiene ninguna que leer.
|
|
662
|
+
// SI LE LLEGAN LOS AVISOS, en quien aprueba. No cambia a quién se le pide —se le pide a
|
|
663
|
+
// todos—: es para ver a cuál no le suena el pedido.
|
|
664
|
+
if (m.caps.includes('approve') && 'notifiedAt' in m) console.log(' %s%s%s', D, avisosTxt(m.notifiedAt), Z)
|
|
634
665
|
if (m.cn && !m.canSeal && m.caps.includes('secrets')) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
|
|
635
666
|
// QUÉ CORRE, y si cuadra con esta bóveda (CONVENCIONES §14). Sin esto una
|
|
636
667
|
// incompatibilidad de versiones se ve como que ese aparato «no responde», que es el
|
|
@@ -1793,7 +1824,32 @@ async function waitForService (version, { timeoutMs = 4 * 60_000 } = {}) {
|
|
|
1793
1824
|
return false
|
|
1794
1825
|
}
|
|
1795
1826
|
|
|
1827
|
+
/**
|
|
1828
|
+
* DEJA DICHO QUE SE INSTALÓ una versión nueva, para que el servicio se lo cuente a quien
|
|
1829
|
+
* aprueba cuando arranque con ella (`anunciarActualizacion` en daemon.js). Es el mismo
|
|
1830
|
+
* marcador que deja el servicio cuando se actualiza solo. Si no se puede escribir se dice:
|
|
1831
|
+
* la actualización está hecha, lo que se pierde es el aviso.
|
|
1832
|
+
*/
|
|
1833
|
+
function noteUpdated (to) {
|
|
1834
|
+
try { ipcWrite(path.join(dir, 'updated.json'), { v: 1, from: readState().version || VERSION, to, at: Date.now() }) } catch (e) {
|
|
1835
|
+
console.error('No se pudo dejar el aviso de actualización para tus aprobadores: %s', e.message)
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1796
1839
|
async function cmdUpdate (args = []) {
|
|
1840
|
+
// PEDIR PERMISO PARA ACTUALIZARSE es un ajuste, apagado por defecto. Se guarda aquí y el
|
|
1841
|
+
// servicio lo lee la próxima vez que mire si hay versión nueva: no hace falta reiniciarlo.
|
|
1842
|
+
const iAp = args.indexOf('--approval')
|
|
1843
|
+
if (iAp !== -1) {
|
|
1844
|
+
const { readSettings, writeSettings } = await import('./settings.js')
|
|
1845
|
+
const v = args[iAp + 1]
|
|
1846
|
+
if (v === 'on' || v === 'off') writeSettings(dir, { updateApproval: v === 'on' })
|
|
1847
|
+
else if (v !== undefined) { console.error('uso: dotrino-vault update --approval [on|off]'); process.exit(2) }
|
|
1848
|
+
console.log(readSettings(dir).updateApproval
|
|
1849
|
+
? 'Esta bóveda pide aprobación antes de actualizarse (a los aparatos con «aprueba» de cualquiera de sus perfiles).'
|
|
1850
|
+
: 'Esta bóveda se actualiza sola, sin pedir aprobación.')
|
|
1851
|
+
return
|
|
1852
|
+
}
|
|
1797
1853
|
const { latestRelease, assetFor, download, verifyArtifact, isNewer, isUserInstall, installUserRelease } = await import('./update.js')
|
|
1798
1854
|
const soloMirar = args.includes('--check')
|
|
1799
1855
|
|
|
@@ -1825,6 +1881,7 @@ async function cmdUpdate (args = []) {
|
|
|
1825
1881
|
}
|
|
1826
1882
|
console.log('firmada por el release de %s ✓', 'imdotrino/dotrino-vault')
|
|
1827
1883
|
console.log('Instalada la %s.', res.version)
|
|
1884
|
+
noteUpdated(res.version)
|
|
1828
1885
|
if (!args.includes('--no-wait')) await waitForService(res.version)
|
|
1829
1886
|
return
|
|
1830
1887
|
}
|
|
@@ -1864,6 +1921,7 @@ async function cmdUpdate (args = []) {
|
|
|
1864
1921
|
B, file, r.version, Z)
|
|
1865
1922
|
return
|
|
1866
1923
|
}
|
|
1924
|
+
noteUpdated(r.version)
|
|
1867
1925
|
console.log('Listo. El servicio se reinicia solo; compruébalo con: dotrino-vault status')
|
|
1868
1926
|
}
|
|
1869
1927
|
|
|
@@ -2174,6 +2232,10 @@ function help () {
|
|
|
2174
2232
|
status | info estado del servicio + fingerprint + el id de cada perfil
|
|
2175
2233
|
update [--check] trae la versión nueva (verificando la firma) y espera a que el
|
|
2176
2234
|
servicio la arranque · --check solo mira · --no-wait no espera
|
|
2235
|
+
update --approval [on|off]
|
|
2236
|
+
el servicio se actualiza solo; con «on» antes pide aprobación a los
|
|
2237
|
+
aparatos con «aprueba» de cualquier perfil (vale para todos) · sin
|
|
2238
|
+
valor, dice cómo está
|
|
2177
2239
|
pair [--save <f>] inicia un emparejamiento (QR + espera); --save escribe la invitación (.dpair)
|
|
2178
2240
|
pair --kms <config.json>
|
|
2179
2241
|
el sitio que se cree (--adopt o --new-account) NACE con su clave
|
package/src/daemon.js
CHANGED
|
@@ -27,10 +27,16 @@ import { capScope } from '@dotrino/identity/acta'
|
|
|
27
27
|
import { watchBinary } from './selfupdate.js'
|
|
28
28
|
import { latestRelease, isNewer, CHECK_EVERY_MS, isUserInstall, installUserRelease } from './update.js'
|
|
29
29
|
import { VERSION } from './version.js'
|
|
30
|
+
import { readSettings } from './settings.js'
|
|
31
|
+
import { approversOfAll, firstYes, readAsked, writeAsked, clearAsked, shouldAsk, resultOfNo } from './updateApproval.js'
|
|
32
|
+
import { UPDATE_TTL_MS } from './approvals.js'
|
|
30
33
|
import crypto from 'node:crypto'
|
|
31
34
|
|
|
32
35
|
const readJsonSafe = (f) => ipcRead(f, null)
|
|
33
36
|
const rm = (f) => { try { fs.rmSync(f, { force: true }) } catch (_) {} }
|
|
37
|
+
/** Cada cuánto se reintenta contar la actualización, y cuántas veces (≈10 min). */
|
|
38
|
+
const ANNOUNCE_EVERY_MS = 5000
|
|
39
|
+
const ANNOUNCE_TRIES = 120
|
|
34
40
|
|
|
35
41
|
/**
|
|
36
42
|
* UNA sola bóveda por directorio de datos.
|
|
@@ -89,18 +95,24 @@ export async function runDaemon () {
|
|
|
89
95
|
|
|
90
96
|
// --- state.json ---
|
|
91
97
|
const stateFile = path.join(dir, 'state.json')
|
|
98
|
+
// Lo que deja quien instala una versión nueva, para que la nueva lo cuente al arrancar.
|
|
99
|
+
const updatedFile = path.join(dir, 'updated.json')
|
|
100
|
+
/** Los perfiles que están atendiendo ahora mismo, con su nombre para los mensajes. */
|
|
101
|
+
const perfilesEnMarcha = () => [...mgr.running.entries()].map(([id, vault]) => ({ id, vault, name: mgr.profiles.get(id)?.name || id }))
|
|
92
102
|
const daemonVersion = VERSION
|
|
93
103
|
/** `{ version, checkedAt }` de la última consulta que salió bien, o null si aún ninguna. */
|
|
94
104
|
let ultimaPublicada = null
|
|
95
105
|
/**
|
|
96
|
-
* EL PEDIDO DE ACTUALIZACIÓN
|
|
97
|
-
*
|
|
98
|
-
*
|
|
106
|
+
* EL PEDIDO DE ACTUALIZACIÓN: `{ version, askedAt, result, approvers }`, o null. `result`
|
|
107
|
+
* es `pending` mientras espera (un día) y `denied`/`expired` cuando no se aprobó — y
|
|
108
|
+
* entonces no se vuelve a pedir por esa versión. Va en la foto para que `status` y la TUI
|
|
109
|
+
* lo DIGAN: una bóveda que no se actualiza por esto se vería igual que una al día.
|
|
99
110
|
*/
|
|
100
111
|
let pedidoSinRespuesta = null
|
|
101
112
|
// Los campos de la raíz (fingerprint/iss) son los del perfil ACTIVO: los leen el
|
|
102
113
|
// instalador y la web, que son anteriores al multi-perfil. La lista completa va
|
|
103
114
|
// en `profiles`.
|
|
115
|
+
const ajusteActualizar = () => { try { return readSettings(dir).updateApproval } catch (_) { return null } }
|
|
104
116
|
const writeState = () => {
|
|
105
117
|
const cur = mgr.summary().find((p) => p.current) || {}
|
|
106
118
|
ipcWrite(stateFile, {
|
|
@@ -114,6 +126,9 @@ export async function runDaemon () {
|
|
|
114
126
|
// también sin conexión.
|
|
115
127
|
...(ultimaPublicada ? { latest: ultimaPublicada } : {}),
|
|
116
128
|
...(pedidoSinRespuesta ? { updateAsk: pedidoSinRespuesta } : {}),
|
|
129
|
+
// Si para actualizarse pide permiso (`src/settings.js`). `null` = el ajuste no se
|
|
130
|
+
// pudo leer, que `status` dice distinto de «no».
|
|
131
|
+
updateApproval: ajusteActualizar(),
|
|
117
132
|
current: mgr.currentId(), profiles: mgr.summary()
|
|
118
133
|
})
|
|
119
134
|
}
|
|
@@ -1103,35 +1118,44 @@ export async function runDaemon () {
|
|
|
1103
1118
|
if (!isNewer(r.version, daemonVersion)) return
|
|
1104
1119
|
|
|
1105
1120
|
/**
|
|
1106
|
-
*
|
|
1121
|
+
* SE ACTUALIZA SOLA. PEDIR PERMISO ES UN AJUSTE (dueño, 2026-10-08).
|
|
1107
1122
|
*
|
|
1108
|
-
*
|
|
1109
|
-
*
|
|
1110
|
-
*
|
|
1111
|
-
*
|
|
1123
|
+
* Por defecto no se le pregunta a nadie: lo que se baja se verifica contra la
|
|
1124
|
+
* atestación del release antes de tocar el disco, y eso no depende de que alguien
|
|
1125
|
+
* conteste. Antes se pedía permiso según quién hubiera en el acta y según si decía que
|
|
1126
|
+
* le llegan los avisos; eran condiciones que explicar y que se desalineaban, y una
|
|
1127
|
+
* bóveda podía quedarse atrás sin que nadie lo viera.
|
|
1112
1128
|
*
|
|
1113
|
-
*
|
|
1114
|
-
*
|
|
1115
|
-
*
|
|
1129
|
+
* Quien quiera decidir cada actualización lo enciende: `dotrino-vault update --approval
|
|
1130
|
+
* on` (`src/settings.js`). Entonces se le pide a TODO el que tenga `aprueba`, reciba
|
|
1131
|
+
* avisos o no, y sin un sí no se instala. Si en el acta no aprueba nadie, se actualiza
|
|
1132
|
+
* igual: esperar un sí que nadie puede dar sería no actualizarse nunca.
|
|
1133
|
+
*
|
|
1134
|
+
* Lo que cada aprobador dijo de sus avisos (`notifiedAt`, `src/notifiable.js`) no
|
|
1135
|
+
* decide nada: se enseña junto al pedido sin respuesta, para ver a cuál no le suena.
|
|
1116
1136
|
*/
|
|
1117
|
-
// SI NO SE PUEDE
|
|
1118
|
-
//
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
console.log(`[vault] version ${r.version} is out, but the approvers could not be read (${e?.message || e}) · not updating`)
|
|
1137
|
+
// SI EL AJUSTE NO SE PUEDE LEER, NO SE ACTUALIZA: el valor por defecto es no preguntar,
|
|
1138
|
+
// así que suponerlo sería saltarse la aprobación que el dueño pidió.
|
|
1139
|
+
let pedirPermiso
|
|
1140
|
+
try { pedirPermiso = readSettings(dir).updateApproval } catch (e) {
|
|
1141
|
+
console.log(`[vault] version ${r.version} is out, but the settings could not be read (${e?.message || e}) · not updating`)
|
|
1123
1142
|
return
|
|
1124
1143
|
}
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1144
|
+
// EN TODOS LOS PERFILES: el ajuste es de la bóveda, no de una cuenta (dueño, 2026-10-08).
|
|
1145
|
+
// Basta con que UNO tenga quien apruebe para que se pida, y se le pide a todos.
|
|
1146
|
+
//
|
|
1147
|
+
// Y si hay que pedir y no se puede saber a quién, tampoco se actualiza: una lista vacía
|
|
1148
|
+
// o a medias sería «nadie aprueba, adelante», el repliegue que abre la puerta justo
|
|
1149
|
+
// cuando algo se rompió.
|
|
1150
|
+
const enMarcha = perfilesEnMarcha()
|
|
1151
|
+
let quienAprueba = []
|
|
1152
|
+
if (pedirPermiso) {
|
|
1153
|
+
try { quienAprueba = await approversOfAll(enMarcha) } catch (e) {
|
|
1154
|
+
console.log(`[vault] version ${r.version} is out, but the approvers could not be read (${e?.message || e}) · not updating`)
|
|
1155
|
+
return
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
const conAvisos = quienAprueba.filter((a) => a.notifiedAt).length
|
|
1135
1159
|
|
|
1136
1160
|
// INSTALADA COMO PAQUETE DEL SISTEMA (.deb en /usr/bin): el binario es de root y este
|
|
1137
1161
|
// proceso no puede tocarlo. Se dice, con las dos salidas, y no se intenta nada.
|
|
@@ -1142,23 +1166,55 @@ export async function runDaemon () {
|
|
|
1142
1166
|
if (actualizando) return
|
|
1143
1167
|
actualizando = true
|
|
1144
1168
|
try {
|
|
1145
|
-
//
|
|
1146
|
-
//
|
|
1147
|
-
if (quienAprueba.length) {
|
|
1148
|
-
|
|
1149
|
-
|
|
1169
|
+
// (`aprobada`: ya dijeron que sí a esta versión y la instalación falló; no se vuelve a
|
|
1170
|
+
// preguntar por lo que ya se aprobó, se reintenta instalar.)
|
|
1171
|
+
if (quienAprueba.length && aprobada !== r.version) {
|
|
1172
|
+
// UNA VEZ POR VERSIÓN (dueño, 2026-10-08): el pedido dura un día y no se repite. Si
|
|
1173
|
+
// se denegó o venció, esa versión queda sin aprobar y se vuelve a preguntar solo
|
|
1174
|
+
// cuando salga una MÁS NUEVA. Lo preguntado se apunta al pedir y sobrevive a un
|
|
1175
|
+
// reinicio (`update-asked.json`); si no se puede leer, no se actualiza y se dice.
|
|
1176
|
+
let preguntado
|
|
1177
|
+
try { preguntado = readAsked(dir) } catch (e) {
|
|
1178
|
+
console.log(`[vault] version ${r.version} is out, but what was already asked could not be read (${e?.message || e}) · not updating`)
|
|
1179
|
+
return
|
|
1180
|
+
}
|
|
1181
|
+
const quienes = quienAprueba.map((a) => ({ profile: a.profile, id: a.id, label: a.label, notifiedAt: a.notifiedAt }))
|
|
1182
|
+
if (!shouldAsk(preguntado, r.version, isNewer)) {
|
|
1183
|
+
// Un «pendiente» que no es de este proceso es de antes de un reinicio: el pedido
|
|
1184
|
+
// murió con él, y nadie puede contestarlo ya.
|
|
1185
|
+
if (preguntado.result === 'pending') { preguntado = { ...preguntado, result: 'expired' }; writeAsked(dir, preguntado) }
|
|
1186
|
+
const nuevo = pedidoSinRespuesta?.version !== preguntado.version || pedidoSinRespuesta?.result !== preguntado.result
|
|
1187
|
+
pedidoSinRespuesta = { ...preguntado, approvers: quienes }
|
|
1188
|
+
writeState()
|
|
1189
|
+
if (nuevo) console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · approval for ${preguntado.version} was asked on ${new Date(preguntado.askedAt).toISOString()} and not given (${preguntado.result}) · not asking again until a newer version · install it with: dotrino-vault update`)
|
|
1190
|
+
return
|
|
1191
|
+
}
|
|
1192
|
+
const askedAt = Date.now()
|
|
1193
|
+
writeAsked(dir, { version: r.version, askedAt, result: 'pending' })
|
|
1194
|
+
pedidoSinRespuesta = { version: r.version, askedAt, result: 'pending', approvers: quienes }
|
|
1195
|
+
writeState()
|
|
1196
|
+
console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · asking ${quienAprueba.length} approver(s) across ${new Set(quienAprueba.map((a) => a.profileId)).size} profile(s), once · ${conAvisos} of them said notifications reach it`)
|
|
1197
|
+
// Un pedido por perfil que tenga quien apruebe, a la vez. El primer sí basta, y los
|
|
1198
|
+
// que queden vivos en los demás se retiran (`src/updateApproval.js`).
|
|
1199
|
+
const conAprobador = new Set(quienAprueba.map((a) => a.profileId))
|
|
1200
|
+
const si = await firstYes(enMarcha.filter((p) => conAprobador.has(p.id)).map((p) => p.vault.askUpdateApproval({ version: r.version })))
|
|
1150
1201
|
if (!si) {
|
|
1151
|
-
const
|
|
1152
|
-
|
|
1202
|
+
const result = resultOfNo(askedAt, UPDATE_TTL_MS)
|
|
1203
|
+
writeAsked(dir, { version: r.version, askedAt, result })
|
|
1204
|
+
pedidoSinRespuesta = { version: r.version, askedAt, result, approvers: quienes }
|
|
1153
1205
|
writeState()
|
|
1154
|
-
console.log(`[vault] update to ${r.version}: not approved (
|
|
1206
|
+
console.log(`[vault] update to ${r.version}: not approved (${result}) · it will not ask again for this version · install it with: dotrino-vault update`)
|
|
1155
1207
|
return
|
|
1156
1208
|
}
|
|
1157
|
-
|
|
1158
|
-
|
|
1209
|
+
aprobada = r.version
|
|
1210
|
+
} else if (quienAprueba.length) {
|
|
1211
|
+
console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · already approved: installing`)
|
|
1212
|
+
} else if (pedirPermiso) {
|
|
1213
|
+
console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · approval is on but no profile has an approver: updating on its own`)
|
|
1159
1214
|
} else {
|
|
1160
|
-
console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) ·
|
|
1215
|
+
console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · updating on its own`)
|
|
1161
1216
|
}
|
|
1217
|
+
clearAsked(dir)
|
|
1162
1218
|
if (pedidoSinRespuesta) { pedidoSinRespuesta = null; writeState() }
|
|
1163
1219
|
const res = await installUserRelease(r, { log: (m) => console.log(m) })
|
|
1164
1220
|
if (!res.ok) {
|
|
@@ -1171,6 +1227,12 @@ export async function runDaemon () {
|
|
|
1171
1227
|
// minutos con el CLI y la TUI nuevos hablando con el daemon viejo; el dueño abrió la
|
|
1172
1228
|
// TUI justo ahí y no arrancó (2026-09-24). Solo bajo systemd, que es quien nos levanta:
|
|
1173
1229
|
// sin él, irnos sería quedarnos apagados.
|
|
1230
|
+
// DEJAR DICHO QUE SE INSTALÓ, para que la versión nueva se lo cuente a quien aprueba al
|
|
1231
|
+
// arrancar (`anunciarActualizacion`). Si no se puede escribir se dice: la actualización
|
|
1232
|
+
// está hecha igual, lo que se pierde es el aviso.
|
|
1233
|
+
try { ipcWrite(updatedFile, { v: 1, from: daemonVersion, to: r.version, at: Date.now() }) } catch (e) {
|
|
1234
|
+
console.log(`[vault] update: ${r.version} installed, but the note to tell the approvers could not be written: ${e.message}`)
|
|
1235
|
+
}
|
|
1174
1236
|
if (vigia) {
|
|
1175
1237
|
console.log(`[vault] update: ${r.version} installed · restarting now to run it`)
|
|
1176
1238
|
shutdown(`updated to ${r.version}`)
|
|
@@ -1182,6 +1244,45 @@ export async function runDaemon () {
|
|
|
1182
1244
|
} finally { actualizando = false }
|
|
1183
1245
|
}
|
|
1184
1246
|
let actualizando = false
|
|
1247
|
+
let aprobada = null
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* «ME ACTUALICÉ»: se lo cuenta a quien aprueba la versión NUEVA, ya corriendo (dueño,
|
|
1251
|
+
* 2026-10-08). Quien instaló —este daemon o `dotrino-vault update`— dejó `updated.json`;
|
|
1252
|
+
* si dice la versión que corre ahora, cada perfil avisa a sus aprobadores en cuanto está
|
|
1253
|
+
* identificado en el proxio, y el marcador se borra cuando salió en todos.
|
|
1254
|
+
*
|
|
1255
|
+
* No se borra lo que no se pudo leer ni lo que no salió: se dice y se reintenta. Un
|
|
1256
|
+
* marcador de una versión ya superada sí se quita: esa noticia caducó.
|
|
1257
|
+
*/
|
|
1258
|
+
const anunciarActualizacion = () => {
|
|
1259
|
+
if (!fs.existsSync(updatedFile)) return
|
|
1260
|
+
const m = ipcRead(updatedFile, null)
|
|
1261
|
+
if (!m || typeof m.to !== 'string') { console.log('[vault] update: updated.json exists but cannot be read · the approvers were not told'); return }
|
|
1262
|
+
if (m.to !== daemonVersion) {
|
|
1263
|
+
if (isNewer(daemonVersion, m.to)) rm(updatedFile)
|
|
1264
|
+
return // todavía no corre esa versión: se anuncia cuando arranque
|
|
1265
|
+
}
|
|
1266
|
+
const hechos = new Set()
|
|
1267
|
+
let intentos = 0
|
|
1268
|
+
const intento = async () => {
|
|
1269
|
+
intentos++
|
|
1270
|
+
let falta = false
|
|
1271
|
+
for (const p of perfilesEnMarcha()) {
|
|
1272
|
+
if (hechos.has(p.id)) continue
|
|
1273
|
+
if (!p.vault.isIdentified?.()) { falta = true; continue }
|
|
1274
|
+
try { await p.vault.announceUpdate({ version: m.to, from: typeof m.from === 'string' ? m.from : null }); hechos.add(p.id) } catch (e) {
|
|
1275
|
+
falta = true
|
|
1276
|
+
if (intentos === 1 || intentos === ANNOUNCE_TRIES) console.log(`[vault] update: profile ${p.name} could not tell its approvers yet: ${e?.message || e}`)
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
if (!falta) { rm(updatedFile); return }
|
|
1280
|
+
if (intentos >= ANNOUNCE_TRIES) { console.log('[vault] update: some approvers were not told · it will try again on the next start'); return }
|
|
1281
|
+
setTimeout(intento, ANNOUNCE_EVERY_MS).unref?.()
|
|
1282
|
+
}
|
|
1283
|
+
setTimeout(intento, ANNOUNCE_EVERY_MS).unref?.()
|
|
1284
|
+
}
|
|
1285
|
+
anunciarActualizacion()
|
|
1185
1286
|
mirarRelease()
|
|
1186
1287
|
const relojRelease = setInterval(mirarRelease, CHECK_EVERY_MS)
|
|
1187
1288
|
relojRelease.unref?.()
|
package/src/notices.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LO QUE LA BÓVEDA TIENE QUE CONTARLE A QUIEN APRUEBA, y que no es un pedido.
|
|
3
|
+
*
|
|
4
|
+
* Hoy es uno: «me actualicé» —la bóveda o uno de sus aparatos— (`ev: 'updated'`, dueño 2026-10-08: «las actualizaciones, que se
|
|
5
|
+
* envíe una notificación de que ocurrieron a los aprobadores»). Sale como aviso por el
|
|
6
|
+
* proxio, pero el timbre de una app nativa llega VACÍO —lo leen Google y Apple—, así que la
|
|
7
|
+
* app pregunta por su lista (`op: 'approvals'`) y lo lee de aquí, en `notices`.
|
|
8
|
+
*
|
|
9
|
+
* Caduca a las 24 h: es una noticia, no un registro (eso es la bitácora).
|
|
10
|
+
*
|
|
11
|
+
* Cifrado en reposo como lo demás. Un archivo que existe y no se puede leer se LANZA: callar
|
|
12
|
+
* sería decir «no pasó nada» de algo que sí pasó.
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import crypto from 'node:crypto'
|
|
17
|
+
|
|
18
|
+
export const NOTICES_FILE = 'notices.json'
|
|
19
|
+
/** Cuánto se sigue contando un aviso. */
|
|
20
|
+
export const NOTICE_TTL_MS = 24 * 60 * 60 * 1000
|
|
21
|
+
|
|
22
|
+
const fail = (code, message) => Object.assign(new Error(message), { code })
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{ dir: string, atRest: { encrypt: (s: string) => string, decrypt: (s: string) => string }, now?: () => number }} opts
|
|
26
|
+
*/
|
|
27
|
+
export function openNotices ({ dir, atRest, now = Date.now }) {
|
|
28
|
+
const file = path.join(dir, NOTICES_FILE)
|
|
29
|
+
let items = load()
|
|
30
|
+
|
|
31
|
+
function load () {
|
|
32
|
+
if (!fs.existsSync(file)) return []
|
|
33
|
+
try {
|
|
34
|
+
const d = JSON.parse(atRest.decrypt(fs.readFileSync(file, 'utf8')))
|
|
35
|
+
if (!d || !Array.isArray(d.items)) throw new Error('not a list')
|
|
36
|
+
return d.items.filter((n) => n && typeof n.id === 'string' && typeof n.ts === 'number')
|
|
37
|
+
} catch (e) { throw fail('notices-unreadable', `${NOTICES_FILE} cannot be read: ${e.message}`) }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function save () {
|
|
41
|
+
const tmp = file + '.tmp'
|
|
42
|
+
fs.writeFileSync(tmp, atRest.encrypt(JSON.stringify({ v: 1, items })), { mode: 0o600 })
|
|
43
|
+
fs.renameSync(tmp, file)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const alive = (n) => now() - n.ts < NOTICE_TTL_MS
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
/**
|
|
50
|
+
* Apunta que algo se actualizó. Devuelve el aviso tal como viaja. `product`: qué
|
|
51
|
+
* (`@dotrino/vaultd` es la propia bóveda); `deviceId`/`label`: el aparato, si fue uno.
|
|
52
|
+
*/
|
|
53
|
+
updated ({ version, from = null, product = '@dotrino/vaultd', deviceId = null, label = null }) {
|
|
54
|
+
if (typeof version !== 'string' || !version) throw fail('bad-notice', 'updated: a version is required')
|
|
55
|
+
if (typeof product !== 'string' || !product) throw fail('bad-notice', 'updated: a product is required')
|
|
56
|
+
const n = {
|
|
57
|
+
id: crypto.randomBytes(8).toString('hex'), ev: 'updated', product, version, from: typeof from === 'string' ? from : null,
|
|
58
|
+
...(typeof deviceId === 'string' && deviceId ? { deviceId, label: typeof label === 'string' ? label : '' } : {}),
|
|
59
|
+
ts: now()
|
|
60
|
+
}
|
|
61
|
+
items = [...items.filter(alive), n]
|
|
62
|
+
save()
|
|
63
|
+
return n
|
|
64
|
+
},
|
|
65
|
+
/** Los avisos vivos, del más viejo al más nuevo. */
|
|
66
|
+
list () { return items.filter(alive).map((n) => ({ ...n })) }
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/notifiable.js
CHANGED
|
@@ -2,24 +2,25 @@
|
|
|
2
2
|
* QUÉ APROBADORES PUEDEN RECIBIR UN AVISO: lo dice cada uno, y caduca.
|
|
3
3
|
*
|
|
4
4
|
* Un aparato con `approve` que no recibe avisos es un aprobador solo en el papel: el pedido
|
|
5
|
-
* no le suena y solo lo ve si abre la app por su cuenta.
|
|
6
|
-
* bloqueo mudo — la bóveda pedía cada día, nadie se enteraba y se quedaba atrás para siempre
|
|
7
|
-
* (dueño, 2026-10-07).
|
|
5
|
+
* no le suena y solo lo ve si abre la app por su cuenta.
|
|
8
6
|
*
|
|
9
7
|
* Así que el aprobador lo declara: al pedir su lista de pedidos (`op: 'approvals'`, que ya
|
|
10
8
|
* manda firmado cada vez que se abre) dice `notify: true` si tiene los avisos encendidos.
|
|
11
9
|
* Aquí se apunta CUÁNDO lo dijo por última vez, y vale `NOTIFIABLE_TTL_MS`: una app que se
|
|
12
|
-
* desinstala deja de renovarlo
|
|
10
|
+
* desinstala deja de renovarlo.
|
|
13
11
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* NO DECIDE NADA: SE ENSEÑA (dueño, 2026-10-08). Un día decidió si la bóveda pedía permiso
|
|
13
|
+
* para actualizarse —sin nadie que lo hubiera dicho, se actualizaba sola— y se quitó: era
|
|
14
|
+
* una condición más, y el silencio de un aprobador abría la puerta. Quién aprueba lo dice
|
|
15
|
+
* el acta y nada más; los avisos salen a todos los que tienen `approve`, lo hayan dicho o
|
|
16
|
+
* no. Esto sirve para VER a cuál no le está sonando el pedido (`members`, `status`, la
|
|
17
|
+
* consola).
|
|
17
18
|
*
|
|
18
19
|
* Y NO SE LE PREGUNTA AL PROXIO, que es quien guarda las suscripciones: es la pieza de la
|
|
19
20
|
* que el diseño no se fía, y creerle sería dejarle decidir cuándo no hace falta permiso.
|
|
20
21
|
*
|
|
21
22
|
* Cifrado en reposo como lo demás. Un archivo que existe y no se puede leer NO es «nadie
|
|
22
|
-
* puede recibir avisos
|
|
23
|
+
* puede recibir avisos»: se lanza.
|
|
23
24
|
*/
|
|
24
25
|
import fs from 'node:fs'
|
|
25
26
|
import path from 'node:path'
|