@mmmbuto/nexuscrew 0.9.51 → 0.9.53
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/CHANGELOG.md +20 -0
- package/docs/NODES.md +1 -1
- package/docs/SECURITY.md +1 -1
- package/frontend/dist/assets/index-DBlFdkZn.js +169 -0
- package/frontend/dist/assets/{index-CcZskBmy.css → index-Da4oBb4f.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +63 -14
- package/lib/cli/messages.js +55 -0
- package/lib/cli/url.js +6 -2
- package/lib/notify/ask-answer-service.js +1 -0
- package/lib/notify/asks.js +58 -7
- package/lib/notify/event-feed-client.js +420 -48
- package/lib/notify/event-feed-routes.js +44 -3
- package/lib/notify/persist.js +69 -12
- package/lib/notify/routes.js +19 -1
- package/lib/prefs/routes.js +52 -0
- package/lib/prefs/store.js +112 -0
- package/lib/proxy/federation.js +49 -5
- package/lib/server.js +112 -34
- package/lib/update/core.js +45 -0
- package/lib/update/manager.js +31 -8
- package/lib/update/npm-env.js +109 -0
- package/lib/update/runner.js +54 -14
- package/lib/update/start-lock.js +106 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-BDdWDcTt.js +0 -169
|
@@ -118,7 +118,21 @@ function createEventFeedRoutes(deps) {
|
|
|
118
118
|
|
|
119
119
|
const timeout = new Promise((resolve) => setTimeout(() => resolve('timeout'), SNAPSHOT_TIMEOUT_MS));
|
|
120
120
|
const work = buildSnapshot(g);
|
|
121
|
-
|
|
121
|
+
let built;
|
|
122
|
+
try {
|
|
123
|
+
built = await Promise.race([work, timeout]);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
// buildSnapshot rifiuta con un errore tipizzato quando non ha potuto
|
|
126
|
+
// leggere gli asks: nessun elenco è un elenco autorevole, quindi non
|
|
127
|
+
// si spedisce Nessuno snapshot — 503 con corpo minimo. Un 200, con
|
|
128
|
+
// qualunque corpo o marcatore, verrebbe deciso dai riceventi che non
|
|
129
|
+
// conoscono il validatore (0.9.51/0.9.52 decidono solo su res.ok).
|
|
130
|
+
if (e && e.reason === 'asks-unreadable') {
|
|
131
|
+
return res.status(503).json({ error: 'asks-unreadable' });
|
|
132
|
+
}
|
|
133
|
+
if (!res.headersSent) return res.status(503).json({ error: 'snapshot failed', reason: 'snapshot-error' });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
122
136
|
if (built === 'timeout') {
|
|
123
137
|
return res.status(503).json({ error: 'snapshot timeout', reason: 'snapshot-timeout' });
|
|
124
138
|
}
|
|
@@ -188,8 +202,27 @@ function createEventFeedRoutes(deps) {
|
|
|
188
202
|
const viewEpoch = st ? st.viewEpoch : null;
|
|
189
203
|
|
|
190
204
|
// Asks: open asks of VISIBLE cells only, capped.
|
|
205
|
+
// Fail closed: se la LETTURA dello store lancia, il produttore non ha un
|
|
206
|
+
// elenco — ha un'assenza di informazione. Spedire asks:[] (con o senza
|
|
207
|
+
// marcatore) farebbe di quell'assenza un elenco AUTOREVOLE: i riceventi
|
|
208
|
+
// senza validatore (0.9.51/0.9.52: `if (!res.ok) continue;` e nient'altro)
|
|
209
|
+
// lo accetterebbero e reconcileImportedAsks chiuderebbe come 'dismissed'
|
|
210
|
+
// alias ancora aperti. Non si spedisce Nessuno snapshot: si rilancia un
|
|
211
|
+
// errore tipizzato che l'handler traduce in 503 {error:"asks-unreadable"}.
|
|
191
212
|
let asks = [];
|
|
192
213
|
try {
|
|
214
|
+
// Health gate PRIMA di list(): un asks.json ESISTENTE ma MALFORMATO
|
|
215
|
+
// (virgola finale, asks non array) produce un list() [] SILENZIOSO — senza
|
|
216
|
+
// eccezione. Quel vuoto viaggerebbe come elenco AUTOREVOLE e i riceventi
|
|
217
|
+
// senza validatore (0.9.51/0.9.52) chiuderebbero alias ancora aperti.
|
|
218
|
+
// Se lo store non e' leggibile, nessuno snapshot: stesso percorso del
|
|
219
|
+
// ramo che lancia sotto (503 asks-unreadable).
|
|
220
|
+
const h = deps.asksStore.health && deps.asksStore.health();
|
|
221
|
+
if (h && !h.readable) {
|
|
222
|
+
const err = new Error(`asks-unreadable: ${h.reason || 'store illeggibile'}`);
|
|
223
|
+
err.reason = 'asks-unreadable';
|
|
224
|
+
throw err;
|
|
225
|
+
}
|
|
193
226
|
const self = typeof deps.localNodeId === 'function' ? deps.localNodeId() : null;
|
|
194
227
|
const open = deps.asksStore.list({ open: true }) || [];
|
|
195
228
|
const visible = open
|
|
@@ -204,7 +237,14 @@ function createEventFeedRoutes(deps) {
|
|
|
204
237
|
.filter((a) => peer.allows({ scope: 'cell', cellId: deps.cellForSession(a.session) }))
|
|
205
238
|
.slice(0, SNAPSHOT_MAX_ASKS);
|
|
206
239
|
asks = visible;
|
|
207
|
-
} catch (
|
|
240
|
+
} catch (e) {
|
|
241
|
+
// Una riga, nessun dato sensibile: il messaggio dello store e' gia'
|
|
242
|
+
// limitato a nome file e causa (v. persist.js readJsonSafe).
|
|
243
|
+
log(`event-feed: asks non leggibili, snapshot non autorevole per il peer ${peer.nodeId}: ${String((e && e.message) || e)}`);
|
|
244
|
+
const err = new Error('asks-unreadable');
|
|
245
|
+
err.reason = 'asks-unreadable';
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
208
248
|
|
|
209
249
|
// Notify history: local-only, ACL-filtered per envelope, capped.
|
|
210
250
|
const notifications = deps.history.list()
|
|
@@ -251,7 +291,8 @@ function createEventFeedRoutes(deps) {
|
|
|
251
291
|
if (asks.length >= SNAPSHOT_MAX_ASKS
|
|
252
292
|
|| notifications.length >= SNAPSHOT_MAX_NOTIFY
|
|
253
293
|
|| (fleetState.cells || []).length >= SNAPSHOT_MAX_CELLS) {
|
|
254
|
-
|
|
294
|
+
// at a cap: the client must treat this as a floor, not a whole.
|
|
295
|
+
built.resyncRequired = true;
|
|
255
296
|
}
|
|
256
297
|
return built;
|
|
257
298
|
}
|
package/lib/notify/persist.js
CHANGED
|
@@ -14,17 +14,21 @@ const crypto = require('node:crypto');
|
|
|
14
14
|
// deve emergere, non essere nascosto da un chmod automatico).
|
|
15
15
|
// Restano NON-errori: file assente ({} — stato iniziale legittimo) e JSON
|
|
16
16
|
// malformato ({} — garbage non deve crashare il server, e non e' un leak).
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
17
|
+
|
|
18
|
+
// Apre il file segreto con le guardie di sicurezza (O_NOFOLLOW + fstat sullo
|
|
19
|
+
// STESSO fd, owner, mode 0600). Le guardie LANCIANO come in readJsonSafe: un
|
|
20
|
+
// symlink, un tipo non regolare, un owner inatteso o permessi larghi devono
|
|
21
|
+
// emergere, non essere riparati in silenzio. Ritorna l'fd aperto; il chiamante
|
|
22
|
+
// chiude. Propaga ENOENT/ELOOP dal openSync (gestiti dai due read).
|
|
23
|
+
function openSecured(p) {
|
|
24
|
+
const fd = fs.openSync(p, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
25
|
+
// Le guardie dopo l'open (fstat, tipo, owner, mode) possono lanciare. L'fd e'
|
|
26
|
+
// gia' aperto: se uscissimo senza chiuderlo qui, i finally dei reader non lo
|
|
27
|
+
// vedrebbero (openSecured e' lanciata prima dell'assegnazione del loro `fd`),
|
|
28
|
+
// e ogni rifiuto lascerebbe un fd aperto. Con 20 rifiuti di fila (file 0644,
|
|
29
|
+
// p.es. un asks.json ripristinato da un backup senz'0600) sono 20 fd persi.
|
|
30
|
+
// Chiudiamo su OGNI errore delle guardie e rilanciamo: il contratto (apre o
|
|
31
|
+
// lancia, mai mezzo) resta, senza leak.
|
|
28
32
|
try {
|
|
29
33
|
const st = fs.fstatSync(fd);
|
|
30
34
|
if (!st.isFile()) {
|
|
@@ -39,6 +43,25 @@ function readJsonSafe(p) {
|
|
|
39
43
|
if ((st.mode & 0o077) !== 0) {
|
|
40
44
|
throw new Error(`refuse to read ${path.basename(p)}: permessi troppo larghi (mode ${(st.mode & 0o777).toString(8)}, atteso 0600)`);
|
|
41
45
|
}
|
|
46
|
+
} catch (e) {
|
|
47
|
+
try { fs.closeSync(fd); } catch (_) { /* best-effort */ }
|
|
48
|
+
throw e;
|
|
49
|
+
}
|
|
50
|
+
return fd;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readJsonSafe(p) {
|
|
54
|
+
let fd;
|
|
55
|
+
try {
|
|
56
|
+
fd = openSecured(p);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
if (e.code === 'ENOENT') return {};
|
|
59
|
+
if (e.code === 'ELOOP') {
|
|
60
|
+
throw new Error(`refuse to read ${path.basename(p)}: symlink (atteso file regolare)`);
|
|
61
|
+
}
|
|
62
|
+
throw e;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
42
65
|
let obj;
|
|
43
66
|
try { obj = JSON.parse(fs.readFileSync(fd, 'utf8')); }
|
|
44
67
|
catch (_) { return {}; } // garbage JSON: non un problema di sicurezza
|
|
@@ -48,6 +71,40 @@ function readJsonSafe(p) {
|
|
|
48
71
|
}
|
|
49
72
|
}
|
|
50
73
|
|
|
74
|
+
// Variante STRETTA di readJsonSafe: distingue assente / valido / malformato.
|
|
75
|
+
// Stesse guardie di sicurezza (lanciano come readJsonSafe). Diverso trattamento
|
|
76
|
+
// di ENOENT e JSON malformato/non-oggetto: NON collassano in {} — restituiscono
|
|
77
|
+
// uno stato esplicito, cosi' il chiamante (asks.js) puo' rifiutare le mutazioni
|
|
78
|
+
// invece di sovrascrivere un file malformato con un defaults vuoto (che
|
|
79
|
+
// cancellerebbe le domande aperte).
|
|
80
|
+
// absent -> { state: 'absent' } (file non esiste)
|
|
81
|
+
// ok -> { state: 'ok', value } (JSON valido, oggetto)
|
|
82
|
+
// malformed -> { state: 'malformed', reason } (parse error o non-oggetto)
|
|
83
|
+
function readJsonStrict(p) {
|
|
84
|
+
let fd;
|
|
85
|
+
try {
|
|
86
|
+
fd = openSecured(p);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
if (e.code === 'ENOENT') return { state: 'absent' };
|
|
89
|
+
if (e.code === 'ELOOP') {
|
|
90
|
+
throw new Error(`refuse to read ${path.basename(p)}: symlink (atteso file regolare)`);
|
|
91
|
+
}
|
|
92
|
+
throw e;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
let obj;
|
|
96
|
+
try { obj = JSON.parse(fs.readFileSync(fd, 'utf8')); }
|
|
97
|
+
catch (e) { return { state: 'malformed', reason: `JSON malformato: ${String((e && e.message) || e)}` }; }
|
|
98
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
99
|
+
const tipo = obj === null ? 'null' : Array.isArray(obj) ? 'array' : typeof obj;
|
|
100
|
+
return { state: 'malformed', reason: `non e' un oggetto JSON (tipo ${tipo})` };
|
|
101
|
+
}
|
|
102
|
+
return { state: 'ok', value: obj };
|
|
103
|
+
} finally {
|
|
104
|
+
try { fs.closeSync(fd); } catch (_) { /* best-effort */ }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
51
108
|
// Scrittura atomica 0600. Rifiuta un target symlink preesistente (ENOENT ok).
|
|
52
109
|
function atomicWriteJson(p, obj) {
|
|
53
110
|
try {
|
|
@@ -70,4 +127,4 @@ function atomicWriteJson(p, obj) {
|
|
|
70
127
|
}
|
|
71
128
|
}
|
|
72
129
|
|
|
73
|
-
module.exports = { readJsonSafe, atomicWriteJson };
|
|
130
|
+
module.exports = { readJsonSafe, readJsonStrict, atomicWriteJson };
|
package/lib/notify/routes.js
CHANGED
|
@@ -451,6 +451,20 @@ function notifyRoutes({
|
|
|
451
451
|
const closed = asks.closeImported({
|
|
452
452
|
ownerId: owner, ownerAskId: b.askId, outcome: b.closeOutcome,
|
|
453
453
|
});
|
|
454
|
+
// closeImported rifiuta ({ok:false, reason:'store-unreadable'}) se lo
|
|
455
|
+
// store locale e' illeggibile: l'alias NON e' stato chiuso. Rispondere
|
|
456
|
+
// comunque 200 'delivered' nasconderebbe il fallimento al mittente: il
|
|
457
|
+
// dispatcher (lib/audio/dispatch.js:91) traduce un 5xx in
|
|
458
|
+
// {status:'unreachable'} — FUORI da DONE/FINAL (closure-retry.js:37-38)
|
|
459
|
+
// — cosi' la coda di recapito lato owner ritenta dopo la riparazione.
|
|
460
|
+
// 'refused' (403/404/409 -> dispatch.js:88) e' FINALE e non va usato:
|
|
461
|
+
// il tempo non ripara un ACL deny, ma ripara uno store illeggibile.
|
|
462
|
+
// No-op idempotente (closed.ok && !closed.changed, store leggibile,
|
|
463
|
+
// alias gia' chiuso o assente) resta 200 delivered come oggi.
|
|
464
|
+
if (closed.ok === false) {
|
|
465
|
+
const reason = closed.reason || 'store-unreadable';
|
|
466
|
+
return res.status(503).json({ status: 'unavailable', reason });
|
|
467
|
+
}
|
|
454
468
|
if (closed.changed && closed.ask) {
|
|
455
469
|
// Il frame porta l'id LOCALE: e' quello con cui questa UI identifica
|
|
456
470
|
// la card, e senza di esso la card resterebbe a schermo.
|
|
@@ -573,7 +587,11 @@ function notifyRoutes({
|
|
|
573
587
|
// chiusura mai recapitata (peer spento mentre l'owner chiudeva): senza,
|
|
574
588
|
// l'alias resta aperto per sempre e la card mente. Best-effort: un owner
|
|
575
589
|
// irraggiungibile non chiude nulla e non fa fallire la lettura.
|
|
576
|
-
|
|
590
|
+
// Best-effort resta il contratto (un owner irraggiungibile non fa
|
|
591
|
+
// fallire la lettura), ma NON in silenzio: la causa si logga.
|
|
592
|
+
if (reconcileImported) {
|
|
593
|
+
try { await reconcileImported(); } catch (e) { try { log(`riconciliazione ask importate fallita: ${String((e && e.message) || e)}`); } catch (_) {} }
|
|
594
|
+
}
|
|
577
595
|
// Recapito recuperabile, lato OWNER: i peer che erano spenti quando la
|
|
578
596
|
// chiusura e' partita vengono ritentati ADESSO. Chi legge lo stato sta
|
|
579
597
|
// guardando le card, ed e' esattamente il momento in cui una card
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const express = require('express');
|
|
3
|
+
const store = require('./store.js');
|
|
4
|
+
|
|
5
|
+
function httpError(status, message, extra) { const e = new Error(message); e.status = status; e.extra = extra; return e; }
|
|
6
|
+
|
|
7
|
+
// Montato sotto /api (gia' dietro il bearer). Ogni richiesta porta anche X-NC-Device: l'id emesso dal server.
|
|
8
|
+
function prefsRoutes({ cfg = {}, prefsPath }) {
|
|
9
|
+
const r = express.Router();
|
|
10
|
+
r.use(express.json({ limit: '64kb' }));
|
|
11
|
+
const readonly = () => cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1';
|
|
12
|
+
const wrap = (fn, { write = false } = {}) => (req, res) => {
|
|
13
|
+
if (write && readonly()) return res.status(403).json({ error: 'READONLY: mutazione preferenze bloccata' });
|
|
14
|
+
try { return fn(req, res); } catch (e) { return res.status(e.status || 500).json({ error: String(e.message || e), ...(e.code ? { code: e.code } : {}), ...(e.extra || {}) }); }
|
|
15
|
+
};
|
|
16
|
+
const deviceOf = (req, st) => {
|
|
17
|
+
const id = String(req.get('x-nc-device') || '');
|
|
18
|
+
if (!store.DEVICE_RE.test(id)) throw httpError(400, 'dispositivo mancante o non valido', { code: 'bad-device' });
|
|
19
|
+
if (!st.devices[id]) throw httpError(404, 'dispositivo sconosciuto', { code: 'unknown-device' });
|
|
20
|
+
return id;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
r.post('/devices', wrap((_req, res) => {
|
|
24
|
+
res.status(201).json({ deviceId: store.issueDevice(prefsPath) });
|
|
25
|
+
}, { write: true }));
|
|
26
|
+
|
|
27
|
+
r.get('/', wrap((req, res) => {
|
|
28
|
+
const st = store.loadStore(prefsPath); const id = deviceOf(req, st); const d = st.devices[id];
|
|
29
|
+
res.set('ETag', `"${d.revision}"`);
|
|
30
|
+
res.json({ deviceId: id, revision: d.revision, updatedAt: d.updatedAt, data: d.data });
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
r.put('/', wrap((req, res) => {
|
|
34
|
+
const st = store.loadStore(prefsPath); const id = deviceOf(req, st); const d = st.devices[id];
|
|
35
|
+
const match = String(req.get('if-match') || '').match(/^"(\d+)"$/);
|
|
36
|
+
if (!match) throw httpError(428, 'If-Match richiesto', { code: 'if-match-required' });
|
|
37
|
+
if (Number(match[1]) !== d.revision) throw httpError(409, 'preferenze modificate altrove', { code: 'revision-conflict', current: { revision: d.revision, data: d.data } });
|
|
38
|
+
const data = store.parseData(req.body && req.body.data);
|
|
39
|
+
if (!data) throw httpError(422, 'preferenze non valide', { code: 'bad-schema' });
|
|
40
|
+
// Mai default vuoti sopra valori esistenti: una copia vuota non sostituisce una piena.
|
|
41
|
+
if (store.isEmpty(data) && !store.isEmpty(d.data)) throw httpError(422, 'copia vuota sopra preferenze esistenti', { code: 'refuse-empty-overwrite' });
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
d.revision += 1; d.updatedAt = now; d.lastSeenAt = now; d.data = data;
|
|
44
|
+
store.atomicWrite(prefsPath, st);
|
|
45
|
+
res.set('ETag', `"${d.revision}"`);
|
|
46
|
+
res.json({ deviceId: id, revision: d.revision, updatedAt: d.updatedAt });
|
|
47
|
+
}, { write: true }));
|
|
48
|
+
|
|
49
|
+
return r;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { prefsRoutes };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Preferenze di interfaccia (pin, ordine, viste, ordine nodi) per DISPOSITIVO, ospitate dal nodo.
|
|
3
|
+
// Servono a ritrovarle quando lo storage del browser viene svuotato. Non contengono mai token ne' diario:
|
|
4
|
+
// lo schema e' una whitelist. Un dispositivo e' un id opaco emesso DAL SERVER (non scelto dal client) e si
|
|
5
|
+
// presenta insieme al bearer: due telefoni con lo stesso token restano isolati.
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const os = require('node:os');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const crypto = require('node:crypto');
|
|
10
|
+
|
|
11
|
+
const SCHEMA_VERSION = 1;
|
|
12
|
+
const MAX_DEVICES = 64;
|
|
13
|
+
const DEVICE_RE = /^[a-f0-9]{32}$/;
|
|
14
|
+
const MAX_KEY = 192; const MAX_LIST = 256; const MAX_POSITIONS = 128;
|
|
15
|
+
|
|
16
|
+
function defaultPrefsPath(home) { return path.join(home || os.homedir(), '.nexuscrew', 'prefs.json'); }
|
|
17
|
+
|
|
18
|
+
const text = (s, max) => {
|
|
19
|
+
if (typeof s !== 'string' || !s || s.length > max) return false;
|
|
20
|
+
for (let i = 0; i < s.length; i += 1) { const c = s.charCodeAt(i); if (c <= 0x1f || c === 0x7f) return false; }
|
|
21
|
+
return true;
|
|
22
|
+
};
|
|
23
|
+
const keyList = (v, max = MAX_LIST) => Array.isArray(v) && v.length <= max && v.every((k) => text(k, MAX_KEY));
|
|
24
|
+
const plain = (v) => v && typeof v === 'object' && !Array.isArray(v);
|
|
25
|
+
|
|
26
|
+
// Ritorna i dati normalizzati oppure null. Chiavi sconosciute => rifiuto (niente campi liberi).
|
|
27
|
+
function parseData(raw) {
|
|
28
|
+
if (!plain(raw)) return null;
|
|
29
|
+
const allowed = new Set(['pins', 'orders', 'views', 'nodeOrder']);
|
|
30
|
+
if (Object.keys(raw).some((k) => !allowed.has(k))) return null;
|
|
31
|
+
const out = { pins: [], orders: {}, views: {}, nodeOrder: [] };
|
|
32
|
+
if (raw.pins !== undefined) { if (!keyList(raw.pins)) return null; out.pins = [...new Set(raw.pins)]; }
|
|
33
|
+
if (raw.nodeOrder !== undefined) { if (!keyList(raw.nodeOrder, 128)) return null; out.nodeOrder = [...new Set(raw.nodeOrder)]; }
|
|
34
|
+
if (raw.orders !== undefined) {
|
|
35
|
+
if (!plain(raw.orders) || Object.keys(raw.orders).length > MAX_POSITIONS) return null;
|
|
36
|
+
for (const [pos, list] of Object.entries(raw.orders)) {
|
|
37
|
+
if (!text(pos, 96) || !keyList(list)) return null;
|
|
38
|
+
out.orders[pos] = [...new Set(list)];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (raw.views !== undefined) {
|
|
42
|
+
if (!plain(raw.views) || Object.keys(raw.views).length > MAX_POSITIONS) return null;
|
|
43
|
+
for (const [pos, v] of Object.entries(raw.views)) {
|
|
44
|
+
if (!text(pos, 96) || !plain(v)) return null;
|
|
45
|
+
const view = {};
|
|
46
|
+
if (v.open !== undefined) { if (typeof v.open !== 'boolean') return null; view.open = v.open; }
|
|
47
|
+
if (v.filter !== undefined) { if (!text(v.filter, 16)) return null; view.filter = v.filter; }
|
|
48
|
+
out.views[pos] = view;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const isEmpty = (d) => !d || (!d.pins.length && !d.nodeOrder.length && !Object.keys(d.orders).length && !Object.keys(d.views).length);
|
|
55
|
+
|
|
56
|
+
function emptyStore() { return { schemaVersion: SCHEMA_VERSION, devices: {} }; }
|
|
57
|
+
|
|
58
|
+
function parseStore(raw) {
|
|
59
|
+
try {
|
|
60
|
+
const obj = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
61
|
+
if (!plain(obj) || obj.schemaVersion !== SCHEMA_VERSION || !plain(obj.devices)) return null;
|
|
62
|
+
const devices = {};
|
|
63
|
+
for (const [id, d] of Object.entries(obj.devices)) {
|
|
64
|
+
if (!DEVICE_RE.test(id) || !plain(d) || !Number.isSafeInteger(d.revision) || d.revision < 0) return null;
|
|
65
|
+
const data = d.data === null || d.data === undefined ? null : parseData(d.data);
|
|
66
|
+
if (d.data && !data) return null;
|
|
67
|
+
devices[id] = { createdAt: Number(d.createdAt) || 0, lastSeenAt: Number(d.lastSeenAt) || 0,
|
|
68
|
+
revision: d.revision, updatedAt: Number(d.updatedAt) || 0, data };
|
|
69
|
+
}
|
|
70
|
+
return { schemaVersion: SCHEMA_VERSION, devices };
|
|
71
|
+
} catch (_) { return null; }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function loadStore(p) {
|
|
75
|
+
let raw;
|
|
76
|
+
try { raw = fs.readFileSync(p, 'utf8'); } catch (e) { if (e.code === 'ENOENT') return emptyStore(); throw e; }
|
|
77
|
+
const parsed = parseStore(raw);
|
|
78
|
+
if (!parsed) { const e = new Error('prefs.json non valido'); e.status = 503; throw e; }
|
|
79
|
+
return parsed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function atomicWrite(p, st) {
|
|
83
|
+
try { if (fs.lstatSync(p).isSymbolicLink()) throw new Error('refuse symlink prefs.json'); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
84
|
+
const parsed = parseStore(st);
|
|
85
|
+
if (!parsed) throw new Error('prefs.json non valido');
|
|
86
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
87
|
+
const tmp = path.join(path.dirname(p), `.${path.basename(p)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
88
|
+
try {
|
|
89
|
+
fs.writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
|
|
90
|
+
fs.chmodSync(tmp, 0o600);
|
|
91
|
+
fs.renameSync(tmp, p);
|
|
92
|
+
} catch (e) { try { fs.unlinkSync(tmp); } catch (_) { /* best effort */ } throw e; }
|
|
93
|
+
return parsed;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Emette un nuovo id dispositivo. Al limite fa posto scartando il piu' vecchio MAI USATO (nessun dato);
|
|
97
|
+
// se tutti hanno dati, rifiuta: mai cancellare preferenze di qualcuno per fare spazio.
|
|
98
|
+
function issueDevice(p, now = Date.now()) {
|
|
99
|
+
const st = loadStore(p);
|
|
100
|
+
const ids = Object.keys(st.devices);
|
|
101
|
+
if (ids.length >= MAX_DEVICES) {
|
|
102
|
+
const unused = ids.filter((id) => st.devices[id].revision === 0).sort((a, b) => st.devices[a].createdAt - st.devices[b].createdAt);
|
|
103
|
+
if (!unused.length) { const e = new Error('limite dispositivi raggiunto'); e.status = 409; e.code = 'device-limit'; throw e; }
|
|
104
|
+
delete st.devices[unused[0]];
|
|
105
|
+
}
|
|
106
|
+
const id = crypto.randomBytes(16).toString('hex');
|
|
107
|
+
st.devices[id] = { createdAt: now, lastSeenAt: now, revision: 0, updatedAt: 0, data: null };
|
|
108
|
+
atomicWrite(p, st);
|
|
109
|
+
return id;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { SCHEMA_VERSION, MAX_DEVICES, DEVICE_RE, defaultPrefsPath, parseData, isEmpty, emptyStore, loadStore, atomicWrite, issueDevice };
|
package/lib/proxy/federation.js
CHANGED
|
@@ -469,7 +469,15 @@ function liveHostLedgerCapture({ ledger, ownerId, resource }) {
|
|
|
469
469
|
};
|
|
470
470
|
}
|
|
471
471
|
|
|
472
|
-
|
|
472
|
+
// An SSE stream is idle BY DESIGN between events. Past the response head the
|
|
473
|
+
// forward timeout must stop applying: the peer's heartbeat is 20 s apart and the
|
|
474
|
+
// GET forward timeout is 10 s, which used to destroy the upstream before the
|
|
475
|
+
// first beat and leave the client hanging on a dead stream until its own idle.
|
|
476
|
+
// What replaces it measures the STREAM's silence, not the socket's.
|
|
477
|
+
const SSE_IDLE_TIMEOUT_MS = 90000;
|
|
478
|
+
const SSE_CONTENT_TYPE_RE = /^text\/event-stream/i;
|
|
479
|
+
|
|
480
|
+
function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null, setCookiePrefix = null, panelCookie = null, capture = null, getTimeoutMs = 10000, sseIdleMs = SSE_IDLE_TIMEOUT_MS }) {
|
|
473
481
|
// A body a local middleware has already read is no longer in the stream: the
|
|
474
482
|
// headers would announce bytes that nothing sends, and the peer would wait for
|
|
475
483
|
// a body that never arrives. Say it at once, with a name, instead of letting
|
|
@@ -477,9 +485,39 @@ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof
|
|
|
477
485
|
if (Number(req.headers['content-length'] || 0) > 0 && req.readableEnded) {
|
|
478
486
|
return res.status(502).json({ error: 'request body already read by a local middleware', reason: 'federation-body-consumed' });
|
|
479
487
|
}
|
|
488
|
+
let streamWatchdog = null;
|
|
489
|
+
const clearStreamWatchdog = () => {
|
|
490
|
+
if (streamWatchdog) { clearTimeout(streamWatchdog); streamWatchdog = null; }
|
|
491
|
+
};
|
|
480
492
|
const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited, hopProof, panelCookie) }, (r) => {
|
|
481
493
|
const headers = setCookiePrefix ? rewriteSetCookiePath(r.headers, setCookiePrefix) : r.headers;
|
|
482
|
-
if (!capture) {
|
|
494
|
+
if (!capture) {
|
|
495
|
+
res.writeHead(r.statusCode, sanitizeResponseHeaders(headers));
|
|
496
|
+
if (SSE_CONTENT_TYPE_RE.test(String(r.headers['content-type'] || ''))) {
|
|
497
|
+
// The response is open: no inactivity timeout any more (the 504 below
|
|
498
|
+
// still covers a peer that never answers), and the watchdog is rearmed
|
|
499
|
+
// on every chunk, so only a stream that really went quiet is ended.
|
|
500
|
+
up.setTimeout(0);
|
|
501
|
+
const rearm = () => {
|
|
502
|
+
clearStreamWatchdog();
|
|
503
|
+
streamWatchdog = setTimeout(() => {
|
|
504
|
+
clearStreamWatchdog();
|
|
505
|
+
// End BOTH sides: leaving the client on a silent stream only makes
|
|
506
|
+
// it wait for its own idle and reconnect anyway.
|
|
507
|
+
try { up.destroy(); } catch (_) { /* already gone */ }
|
|
508
|
+
try { if (!res.writableEnded) res.end(); } catch (_) { /* client gone */ }
|
|
509
|
+
}, sseIdleMs);
|
|
510
|
+
if (typeof streamWatchdog.unref === 'function') streamWatchdog.unref();
|
|
511
|
+
};
|
|
512
|
+
rearm();
|
|
513
|
+
r.on('data', rearm);
|
|
514
|
+
// The client leaving first must take the upstream with it, or the peer
|
|
515
|
+
// keeps a writer on a socket nobody reads.
|
|
516
|
+
res.on('close', () => { clearStreamWatchdog(); try { up.destroy(); } catch (_) { /* gone */ } });
|
|
517
|
+
}
|
|
518
|
+
r.pipe(res);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
483
521
|
// Buffered only where a capture asks for it (the two live-host mutations):
|
|
484
522
|
// the owner's answer carries the revision that has to be recorded.
|
|
485
523
|
const chunks = [];
|
|
@@ -505,11 +543,16 @@ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof
|
|
|
505
543
|
}
|
|
506
544
|
up.destroy();
|
|
507
545
|
});
|
|
508
|
-
up.on('
|
|
546
|
+
up.on('close', clearStreamWatchdog);
|
|
547
|
+
up.on('error', () => {
|
|
548
|
+
clearStreamWatchdog();
|
|
549
|
+
if (!res.headersSent) res.status(502).json({ error: 'peer non raggiungibile', reason: 'federation-peer-unreachable' });
|
|
550
|
+
else res.destroy();
|
|
551
|
+
});
|
|
509
552
|
req.pipe(up);
|
|
510
553
|
}
|
|
511
554
|
|
|
512
|
-
function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false, hopSecret = null, liveHostLedger = null, forwardGetTimeoutMs = 10000 }) {
|
|
555
|
+
function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false, hopSecret = null, liveHostLedger = null, forwardGetTimeoutMs = 10000, forwardSseIdleMs = SSE_IDLE_TIMEOUT_MS }) {
|
|
513
556
|
return (req, res) => {
|
|
514
557
|
const parsed = parseRoute(req.url);
|
|
515
558
|
// Prima dell'allowlist: un peer senza permesso non deve nemmeno sapere se la
|
|
@@ -558,6 +601,7 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
|
|
|
558
601
|
hopProof: signHop(secret, { method: req.method, path, visited }),
|
|
559
602
|
panelCookie,
|
|
560
603
|
getTimeoutMs: forwardGetTimeoutMs,
|
|
604
|
+
sseIdleMs: forwardSseIdleMs,
|
|
561
605
|
});
|
|
562
606
|
}
|
|
563
607
|
const next = st && store.getNode(st, parsed.route[0]);
|
|
@@ -578,7 +622,7 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
|
|
|
578
622
|
const capture = liveHostLedger && (parsed.resource === '/live-host/designate' || parsed.resource === '/live-host/clear')
|
|
579
623
|
? liveHostLedgerCapture({ ledger: liveHostLedger, ownerId: next.nodeId, resource: parsed.resource })
|
|
580
624
|
: null;
|
|
581
|
-
proxyHttp(req, res, { port: next.localPort, path, credential: next.token, visited, setCookiePrefix, panelCookie, capture, getTimeoutMs: forwardGetTimeoutMs });
|
|
625
|
+
proxyHttp(req, res, { port: next.localPort, path, credential: next.token, visited, setCookiePrefix, panelCookie, capture, getTimeoutMs: forwardGetTimeoutMs, sseIdleMs: forwardSseIdleMs });
|
|
582
626
|
};
|
|
583
627
|
}
|
|
584
628
|
|