@mmmbuto/nexuscrew 0.8.57 → 0.9.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/CHANGELOG.md +164 -2
- package/README.md +1 -0
- package/frontend/dist/assets/index-0vuhL1YP.css +32 -0
- package/frontend/dist/assets/index-zjL6kZ7J.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/audio/adapters.js +50 -6
- package/lib/cli/commands.js +40 -2
- package/lib/cli/doctor.js +95 -12
- package/lib/cli/init.js +25 -3
- package/lib/cli/path.js +43 -10
- package/lib/cli/pidfile.js +23 -2
- package/lib/config.js +15 -0
- package/lib/fleet/builtin.js +161 -19
- package/lib/fleet/catalogs/opencode-go.json +328 -0
- package/lib/fleet/cell-exec.js +87 -9
- package/lib/fleet/cell-lease-server.js +719 -0
- package/lib/fleet/cell-lease.js +112 -0
- package/lib/fleet/definitions.js +101 -7
- package/lib/fleet/launch-broker.js +115 -3
- package/lib/fleet/lease-client.js +191 -0
- package/lib/fleet/lease-routes.js +92 -0
- package/lib/fleet/lease-verifier.js +230 -0
- package/lib/fleet/managed.js +444 -55
- package/lib/fleet/prompt-delivery.js +50 -2
- package/lib/fleet/provider.js +1 -1
- package/lib/fleet/runtime.js +53 -6
- package/lib/live-host/bridge.js +369 -0
- package/lib/live-host/routes.js +184 -0
- package/lib/live-host/store.js +96 -0
- package/lib/mcp/tools.js +51 -0
- package/lib/nodes/commands.js +9 -2
- package/lib/nodes/store.js +14 -0
- package/lib/nodes/tunnel.js +4 -1
- package/lib/proxy/federation.js +106 -9
- package/lib/proxy/node-proxy.js +33 -0
- package/lib/proxy/panel-auth.js +307 -0
- package/lib/proxy/panel-proxy.js +305 -0
- package/lib/server.js +127 -4
- package/package.json +1 -1
- package/skills/alibaba-token-media/SKILL.md +19 -0
- package/skills/crew/SKILL.md +15 -0
- package/skills/fill-forms/SKILL.md +23 -0
- package/skills/mail-assistant/SKILL.md +15 -0
- package/skills/memory/SKILL.md +15 -0
- package/skills/nexuscrew-agent/SKILL.md +18 -0
- package/skills/vl-msa/SKILL.md +15 -0
- package/frontend/dist/assets/index-CYi_lhCg.css +0 -32
- package/frontend/dist/assets/index-_c-1_3iR.js +0 -93
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// LeaseManager lato server per il lease del supervisore di una cella Live host.
|
|
4
|
+
//
|
|
5
|
+
// Fetta 2b (contratto rev1): l'autenticazione del reconnect e' un proof HMAC
|
|
6
|
+
// firmato con un verifier PER-INSTALLAZIONE — la capability statica condivisa
|
|
7
|
+
// della 2a e' REVOCATA, non affiancata (A2/B1). Solo il server conosce il
|
|
8
|
+
// segreto; il supervisore presenta un proof firmato con claims ed expiry (B8:
|
|
9
|
+
// issuedAt+60s). Il proof supervisore arriva al detentore sul canale lease
|
|
10
|
+
// (all'attach e a ogni refresh) e non transita mai nel child (R3.1.2 invariato).
|
|
11
|
+
//
|
|
12
|
+
// Storage per-cella (D1/D2): un file per cella in <run>/cell-leases/. Il
|
|
13
|
+
// refresh di una cella rilegge e riscrive SOLO il proprio file: scompare la
|
|
14
|
+
// read-modify-write dell'intero store condiviso, la race su di essa e
|
|
15
|
+
// l'amplificazione O(N^2) misurata in 2a (N letture + N scritture complete
|
|
16
|
+
// per ciclo di 20s). L'invariante di recovery post-restart e' preservato ed
|
|
17
|
+
// affinato: un file corrotto salta solo la propria cella, non lo store intero
|
|
18
|
+
// (E3 chiusa con test). Il nome file usa la stessa sanitizeCell dell'endpoint
|
|
19
|
+
// UDS: due cellId che sanitizzano uguale condividono file ed endpoint, proprieta'
|
|
20
|
+
// gia' vera in 2a per il socket.
|
|
21
|
+
//
|
|
22
|
+
// Rotazione (C3, sospesa per scelta dichiarata): finche' la finestra di
|
|
23
|
+
// sovrapposizione non e' fissata la chiave NON ruota; la verifica interroga
|
|
24
|
+
// comunque la LISTA delle chiavi vive (oggi una) perche' quella e' la forma
|
|
25
|
+
// richiesta da C2 quando la rotazione sara' attivata. Fail-closed sulla
|
|
26
|
+
// verifica (C4); lo stato durevole non contiene mai il segreto, solo
|
|
27
|
+
// identificativo e impronta nel meta del verifier (C5); la verifica rende
|
|
28
|
+
// osservabile quale chiave ha firmato (C6, via log del keyId).
|
|
29
|
+
//
|
|
30
|
+
// Side effect isolati e iniettabili (seams) per testabilita', come altrove nel
|
|
31
|
+
// fleet (cell-exec.js, launch-broker.js). Protocollo sul socket lease:
|
|
32
|
+
// line-oriented JSON, un messaggio per riga.
|
|
33
|
+
// supervisor -> server: {"type":"refresh"}
|
|
34
|
+
// supervisor -> server: {"type":"reconnect","generation":..,"proof":{..}}
|
|
35
|
+
// server -> supervisor: {"type":"lease","leaseId":..,"proof":{..}}
|
|
36
|
+
// | {"type":"ack","proof":{..}} | {"type":"deny"}
|
|
37
|
+
|
|
38
|
+
const net = require('node:net');
|
|
39
|
+
const fs = require('node:fs');
|
|
40
|
+
const path = require('node:path');
|
|
41
|
+
const crypto = require('node:crypto');
|
|
42
|
+
const L = require('./cell-lease.js');
|
|
43
|
+
const { runtimeDir, ensureRuntimeDir } = require('./launch-broker.js');
|
|
44
|
+
const { loadOrCreateVerifier, signProof, verifyProof, PROOF_TTL_MS } = require('./lease-verifier.js');
|
|
45
|
+
|
|
46
|
+
function sanitizeCell(cellId) {
|
|
47
|
+
return String(cellId).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 64);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// cellId ammesso dalle API child: stessa forma usata dalle route fleet.
|
|
51
|
+
const CELL_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
|
|
52
|
+
function validCellId(cellId) { return typeof cellId === 'string' && CELL_ID_RE.test(cellId); }
|
|
53
|
+
|
|
54
|
+
// F-A (audit 2a @ 142e272): il formato che il runtime produce per un'identity.
|
|
55
|
+
// Condiviso da loadPersisted (primo ingresso, al boot) e track (secondo
|
|
56
|
+
// ingresso): la validazione e' UNA, non due copie da tenere allineate.
|
|
57
|
+
const EPOCH_RE = /^[a-f0-9]{16}$/;
|
|
58
|
+
|
|
59
|
+
// A3 — confine del consumo: un proof e' consumato UNA volta, IN-PROCESS. Il
|
|
60
|
+
// registro dei jti muore col processo server: dopo un restart un proof non
|
|
61
|
+
// ancora scaduto puo' ripresentarsi (la firma e l'expiry restano il gate).
|
|
62
|
+
// Garanzia piu' forte (single-use cross-restart) NON promessa dal contratto.
|
|
63
|
+
const JTI_CAP = 4096;
|
|
64
|
+
|
|
65
|
+
function createLeaseManager(cfg = {}, seams = {}) {
|
|
66
|
+
const dir = runtimeDir(cfg);
|
|
67
|
+
const stateDir = cfg.leaseStateDir || path.join(dir, 'cell-leases');
|
|
68
|
+
const now = seams.now || Date.now;
|
|
69
|
+
const setTimer = seams.setTimeout || ((fn, ms) => setTimeout(fn, ms));
|
|
70
|
+
const clearTimer = seams.clearTimeout || ((t) => { if (t) clearTimeout(t); });
|
|
71
|
+
const netImpl = seams.net || net;
|
|
72
|
+
const fsImpl = seams.fs || fs;
|
|
73
|
+
const log = typeof cfg.log === 'function' ? cfg.log : () => {};
|
|
74
|
+
|
|
75
|
+
// cellId -> entry:
|
|
76
|
+
// { launchEpoch, stablePath, stableServer, lease, socket, graceTimer, graceDeadline }
|
|
77
|
+
// lease/socket vivono in memoria (persi al restart); launchEpoch/graceDeadline
|
|
78
|
+
// sono anche persistiti PER CELLA (D1). Nessun segreto in entry o su disco.
|
|
79
|
+
const cells = new Map();
|
|
80
|
+
|
|
81
|
+
// Verifier per-installazione (B7): file dedicato 0o600 nella runtime dir,
|
|
82
|
+
// distinto dai token di liveness e dal segreto del bridge audio. Lazy: nasce
|
|
83
|
+
// al primo proof, cosi' un manager che non firma mai non lascia file in giro.
|
|
84
|
+
let verifier = null;
|
|
85
|
+
function ensureVerifier() {
|
|
86
|
+
if (!verifier) verifier = loadOrCreateVerifier({ dir, fsImpl: fs, log });
|
|
87
|
+
return verifier;
|
|
88
|
+
}
|
|
89
|
+
// Le chiavi vive per la verifica (C2-ready, oggi una: C3 sospesa).
|
|
90
|
+
const liveKeys = () => [ensureVerifier()];
|
|
91
|
+
|
|
92
|
+
// Registro jti consumati (A3): bounded, i scaduti escono da soli.
|
|
93
|
+
const consumedJti = new Map(); // jti -> expiresAt
|
|
94
|
+
function consumeJti(jti, expiresAt) {
|
|
95
|
+
const t = now();
|
|
96
|
+
for (const [j, exp] of consumedJti) if (exp <= t) consumedJti.delete(j);
|
|
97
|
+
if (consumedJti.has(jti)) return false;
|
|
98
|
+
consumedJti.set(jti, expiresAt);
|
|
99
|
+
while (consumedJti.size > JTI_CAP) {
|
|
100
|
+
const oldest = consumedJti.keys().next();
|
|
101
|
+
if (oldest.done) break;
|
|
102
|
+
consumedJti.delete(oldest.value);
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Proof supervisore (kind 'lease'): tupla con leaseId (B6), generation
|
|
108
|
+
// corrente, expiry issuedAt+60s (B8). Emissione == firma: non c'e' stato di
|
|
109
|
+
// sessione da tenere, il detentore presenta il proof cosi' com'e'.
|
|
110
|
+
function issueLeaseProof(cellId, entry, lease) {
|
|
111
|
+
return signProof(ensureVerifier(), {
|
|
112
|
+
kind: 'lease',
|
|
113
|
+
cellId,
|
|
114
|
+
launchEpoch: entry.launchEpoch,
|
|
115
|
+
leaseId: lease.leaseId,
|
|
116
|
+
generation: String(lease.generation),
|
|
117
|
+
jti: crypto.randomBytes(8).toString('hex'),
|
|
118
|
+
issuedAt: now(),
|
|
119
|
+
}, { now });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function cellStatePath(cellId) { return path.join(stateDir, `${sanitizeCell(cellId)}.json`); }
|
|
123
|
+
|
|
124
|
+
function stablePathFor(cellId) { return path.join(dir, `cell-${sanitizeCell(cellId)}.sock`); }
|
|
125
|
+
|
|
126
|
+
// D1: lettura PER CELLA. ENOENT = cella sconosciuta (legittimo); qualunque
|
|
127
|
+
// altro errore (EIO, parse, forma) = illeggibile: propaga, perche' una
|
|
128
|
+
// scrittura che ignora un file illeggibile cancellerebbe lo stato di quella
|
|
129
|
+
// cella senza saperlo.
|
|
130
|
+
function readPersistedCell(cellId) {
|
|
131
|
+
let raw;
|
|
132
|
+
try {
|
|
133
|
+
raw = fsImpl.readFileSync(cellStatePath(cellId), 'utf8');
|
|
134
|
+
} catch (e) {
|
|
135
|
+
if (e && e.code === 'ENOENT') return null;
|
|
136
|
+
throw e;
|
|
137
|
+
}
|
|
138
|
+
const obj = JSON.parse(raw);
|
|
139
|
+
// P1-1b: la FORMA del dato. La root deve essere l'entry attesa: plain-object
|
|
140
|
+
// con i campi del formato per-cell. Qualunque altra cosa e' illeggibile.
|
|
141
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
142
|
+
throw new Error(`store lease: entry per-cella non e' un oggetto: ${sanitizeCell(cellId)}`);
|
|
143
|
+
}
|
|
144
|
+
return obj;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function writePersistedCell(cellId, entryData) {
|
|
148
|
+
// GC1.2 (rev27): l'esito NON si ingoia — ritornato perche' il refresh (e il
|
|
149
|
+
// proof) dipende dal commit effettivo del bound. Il log resta diagnostico.
|
|
150
|
+
try {
|
|
151
|
+
ensureRuntimeDir(dir);
|
|
152
|
+
fsImpl.mkdirSync(stateDir, { recursive: true });
|
|
153
|
+
const target = cellStatePath(cellId);
|
|
154
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
155
|
+
fsImpl.writeFileSync(tmp, JSON.stringify(entryData), { mode: 0o600 });
|
|
156
|
+
fsImpl.renameSync(tmp, target);
|
|
157
|
+
try { fsImpl.chmodSync(target, 0o600); } catch (_) {}
|
|
158
|
+
return true;
|
|
159
|
+
} catch (e) {
|
|
160
|
+
log(`cell-lease: persist failed: ${e && e.message}`);
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// D1: persiste il bound di UNA cella leggendo e scrivendo SOLO il suo file.
|
|
166
|
+
// Non esiste piu' la read-modify-write dell'intero store condiviso.
|
|
167
|
+
// GC1 (rev27): graceDeadline SEMPRE valorizzato (mai null) — bound durevole
|
|
168
|
+
// per rifiutare reconnect oltre la grace post-restart (R3.3.5). Live =
|
|
169
|
+
// now+GRACE_MS; in grace = bound di armGrace; refresh lo rinfresca.
|
|
170
|
+
function persistEntry(cellId, entry) {
|
|
171
|
+
return writePersistedCell(cellId, {
|
|
172
|
+
launchEpoch: entry.launchEpoch,
|
|
173
|
+
graceDeadline: entry.graceDeadline,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function clearGraceTimer(entry) {
|
|
178
|
+
if (entry.graceTimer) { clearTimer(entry.graceTimer); entry.graceTimer = null; }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function armGraceTimer(cellId, entry) {
|
|
182
|
+
clearGraceTimer(entry);
|
|
183
|
+
// R3.2: deadline non estendibile, armata una sola volta.
|
|
184
|
+
const ms = Math.max(0, (entry.lease.graceDeadline - now()));
|
|
185
|
+
entry.graceTimer = setTimer(() => {
|
|
186
|
+
const e = cells.get(cellId);
|
|
187
|
+
if (!e || !e.lease || e.lease.leaseId !== entry.lease.leaseId) return;
|
|
188
|
+
// Grace scaduta senza reconnect: lease terminal. L'eligibilita' (binding host)
|
|
189
|
+
// e' out-of-scope 2a; qui marchiamo il lease come expired.
|
|
190
|
+
log(`cell-lease: ${cellId} grace expired (lease ${entry.lease.leaseId})`);
|
|
191
|
+
}, ms);
|
|
192
|
+
if (entry.graceTimer && typeof entry.graceTimer.unref === 'function') entry.graceTimer.unref();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function detachSocket(entry) {
|
|
196
|
+
if (!entry.socket) return;
|
|
197
|
+
try { entry.socket.removeAllListeners('data'); entry.socket.removeAllListeners('close'); entry.socket.removeAllListeners('end'); entry.socket.destroy(); } catch (_) {}
|
|
198
|
+
entry.socket = null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function bindLiveSocket(cellId, entry, socket, { lease }) {
|
|
202
|
+
// P1-2b (audit 2a05db2): persiste il bound PRIMA di associare il socket. Se il
|
|
203
|
+
// record non committa, rollback e ritorna false: il caller non dichiarera' lease.
|
|
204
|
+
// Diverso da EOF (onEOF): li una write fallita lascia un bound ANTERIORE (fail-closed
|
|
205
|
+
// anticipato, accettabile); qui il bind prometterebbe stato live senza commit durevole.
|
|
206
|
+
const prevGraceDeadline = entry.graceDeadline;
|
|
207
|
+
// GC1 (rev27): bound durevole SEMPRE valorizzato. Lease live -> now+GRACE_MS (bound
|
|
208
|
+
// preventivo); lease in grace -> lease.graceDeadline (bound di armGrace). Mai null.
|
|
209
|
+
entry.graceDeadline = lease && lease.graceDeadline != null ? lease.graceDeadline : now() + L.GRACE_MS;
|
|
210
|
+
if (!persistEntry(cellId, entry)) {
|
|
211
|
+
entry.graceDeadline = prevGraceDeadline; // rollback: socket non associato
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
detachSocket(entry);
|
|
215
|
+
entry.lease = lease;
|
|
216
|
+
entry.socket = socket;
|
|
217
|
+
clearGraceTimer(entry);
|
|
218
|
+
let buf = '';
|
|
219
|
+
const onLine = (line) => {
|
|
220
|
+
let msg; try { msg = JSON.parse(line); } catch (_) { return; }
|
|
221
|
+
if (!msg || typeof msg !== 'object') return;
|
|
222
|
+
if (msg.type === 'refresh') {
|
|
223
|
+
// Heartbeat lato supervisore (R3.2) + GC1.1/GC1.2 (rev27): il refresh
|
|
224
|
+
// rinfresca il bound durevole (now+GRACE_MS) e lo persiste PRIMA dell'ACK
|
|
225
|
+
// e del proof nuovo (hook 2a ora realta'): se la persistenza non committa
|
|
226
|
+
// il refresh NON e' un successo — nessun ACK, nessun proof (il detentore
|
|
227
|
+
// resta col proof vecchio, che scade: fail-closed per scadenza, non per
|
|
228
|
+
// silenzio).
|
|
229
|
+
const cur = cells.get(cellId);
|
|
230
|
+
if (cur && cur.lease && cur.socket === socket) {
|
|
231
|
+
const refreshed = L.refresh(cur.lease, { now: now() });
|
|
232
|
+
if (refreshed) cur.lease = refreshed;
|
|
233
|
+
cur.graceDeadline = now() + L.GRACE_MS; // GC1.1: bound rinfrescato
|
|
234
|
+
if (persistEntry(cellId, cur)) {
|
|
235
|
+
writeSafe(socket, { type: 'ack', proof: issueLeaseProof(cellId, cur, cur.lease) });
|
|
236
|
+
} else {
|
|
237
|
+
log(`cell-lease: ${cellId} refresh: bound non persistito, ACK+proof omessi`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
// Messaggi non riconosciuti su una connessione gia' associata sono ignorati:
|
|
243
|
+
// reconnect e identita' si presentano sull'endpoint stabile, non qui.
|
|
244
|
+
};
|
|
245
|
+
const onData = (chunk) => {
|
|
246
|
+
buf += chunk.toString();
|
|
247
|
+
let nl;
|
|
248
|
+
while ((nl = buf.indexOf('\n')) !== -1) { onLine(buf.slice(0, nl)); buf = buf.slice(nl + 1); }
|
|
249
|
+
};
|
|
250
|
+
const onEOF = () => {
|
|
251
|
+
const cur = cells.get(cellId);
|
|
252
|
+
if (!cur || cur.socket !== socket) return; // gia' sostituita da un reconnect
|
|
253
|
+
// R3.1.3: EOF arma UNA sola transizione monotonica Live -> Grace; deadline
|
|
254
|
+
// non estendibile. armGrace e' no-op se gia' in grace.
|
|
255
|
+
const g = L.armGrace(cur.lease, { now: now() });
|
|
256
|
+
if (g) cur.lease = g;
|
|
257
|
+
// R3.3.5: persistiamo il nuovo bound di grace per rifiutare reconnect stale
|
|
258
|
+
// post-restart (il lease vivo non sopravvive, ma il bound di reject si').
|
|
259
|
+
cur.graceDeadline = cur.lease && cur.lease.graceDeadline != null ? cur.lease.graceDeadline : now() + L.GRACE_MS;
|
|
260
|
+
persistEntry(cellId, cur);
|
|
261
|
+
armGraceTimer(cellId, cur);
|
|
262
|
+
};
|
|
263
|
+
socket.setEncoding('utf8');
|
|
264
|
+
socket.on('data', onData);
|
|
265
|
+
socket.once('close', onEOF);
|
|
266
|
+
socket.once('end', onEOF);
|
|
267
|
+
// Correzione (audit 2a, segnalazione precisata da Dev): questo E' il socket
|
|
268
|
+
// su cui il server SCRIVE (l'ack di refresh — proprio dove l'EPIPE nasce se
|
|
269
|
+
// il peer muore mentre la write e' in volo). Un EventEmitter che emette
|
|
270
|
+
// 'error' senza listener fa un throw che termina l'INTERO processo — non
|
|
271
|
+
// muore la cella, muore NexusCrew con dentro tutte le celle. Assorbe
|
|
272
|
+
// l'errore, non lo propaga: la naturale 'close' che segue attiva comunque
|
|
273
|
+
// onEOF (grace), la stessa forma del close-handler di onStableConnection.
|
|
274
|
+
socket.once('error', () => { try { socket.destroy(); } catch (_) {} });
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function writeSafe(socket, obj) {
|
|
279
|
+
if (!socket || socket.destroyed) return;
|
|
280
|
+
try { socket.write(`${JSON.stringify(obj)}\n`); } catch (_) {}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function onStableConnection(cellId, socket) {
|
|
284
|
+
// Endpoint stabile: reconnect (R3.3.2-4). Legge identita' + proof, valida.
|
|
285
|
+
let buf = '';
|
|
286
|
+
const onData = (chunk) => {
|
|
287
|
+
buf += chunk.toString();
|
|
288
|
+
const nl = buf.indexOf('\n');
|
|
289
|
+
if (nl === -1) return;
|
|
290
|
+
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
|
|
291
|
+
let msg; try { msg = JSON.parse(line); } catch (_) { writeSafe(socket, { type: 'deny' }); socket.destroy(); return; }
|
|
292
|
+
socket.removeAllListeners('data');
|
|
293
|
+
handleReconnect(cellId, socket, msg);
|
|
294
|
+
};
|
|
295
|
+
socket.setEncoding('utf8');
|
|
296
|
+
socket.on('data', onData);
|
|
297
|
+
socket.once('close', () => { try { socket.destroy(); } catch (_) {} });
|
|
298
|
+
// Correzione (audit 2a): stesso motivo di bindLiveSocket — il server scrive
|
|
299
|
+
// 'deny' su questo socket (onData sopra, JSON malformato); senza un
|
|
300
|
+
// handler 'error' un peer che muore mentre quella write e' in volo manda
|
|
301
|
+
// in throw fatale l'intero processo. Se la connessione avanza a
|
|
302
|
+
// handleReconnect->bindLiveSocket, questo handler resta attivo insieme al
|
|
303
|
+
// suo (entrambi fanno solo destroy: innocuo, mai un secondo effetto).
|
|
304
|
+
socket.once('error', () => { try { socket.destroy(); } catch (_) {} });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function denyReconnect(socket) {
|
|
308
|
+
writeSafe(socket, { type: 'deny' });
|
|
309
|
+
socket.destroy();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function handleReconnect(cellId, socket, msg) {
|
|
313
|
+
if (!msg || msg.type !== 'reconnect') return denyReconnect(socket);
|
|
314
|
+
const entry = cells.get(cellId);
|
|
315
|
+
if (!entry) return denyReconnect(socket);
|
|
316
|
+
// A2/B1: l'autenticazione del reconnect e' il proof HMAC. La capability
|
|
317
|
+
// statica della 2a e' revocata: un messaggio senza proof (o col vecchio
|
|
318
|
+
// campo capability) e' negato qui, senza confronti verso segreti condivisi.
|
|
319
|
+
// C4: fail-closed — forma, firma, claims attesi, expiry: ogni difetto e' deny.
|
|
320
|
+
const out = verifyProof(liveKeys(), msg.proof, {
|
|
321
|
+
now,
|
|
322
|
+
expect: {
|
|
323
|
+
kind: 'lease',
|
|
324
|
+
cellId,
|
|
325
|
+
launchEpoch: entry.launchEpoch,
|
|
326
|
+
// leaseId atteso SOLO con lease vivo in memoria: post-restart il lease
|
|
327
|
+
// non sopravvive (R3.3.1) e il gate resta firma+expiry+bound di grace.
|
|
328
|
+
...(entry.lease ? { leaseId: entry.lease.leaseId } : {}),
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
if (!out.ok) {
|
|
332
|
+
log(`cell-lease: ${cellId} reconnect denied (proof: ${out.reason})`);
|
|
333
|
+
return denyReconnect(socket);
|
|
334
|
+
}
|
|
335
|
+
// A3: consumo il jti DOPO la verifica e PRIMA di ogni mutazione. Un proof
|
|
336
|
+
// presentato e negato per altro motivo NON viene consumato: potra' ripresentarsi
|
|
337
|
+
// fino alla propria scadenza, e ogni replica sara' negata dallo stesso gate.
|
|
338
|
+
if (!consumeJti(msg.proof.jti, msg.proof.expiresAt)) {
|
|
339
|
+
log(`cell-lease: ${cellId} reconnect denied (jti replay in-process)`);
|
|
340
|
+
return denyReconnect(socket);
|
|
341
|
+
}
|
|
342
|
+
const generation = Number.isInteger(msg.generation) && msg.generation >= 0 ? msg.generation : (entry.lease ? entry.lease.generation : 0);
|
|
343
|
+
// R3.3.4: transizione di generation VERIFICABILE (non solo non-decreasing). Il
|
|
344
|
+
// supervisore onesto (cell-exec.js) fa avanzare la generation di ESATTAMENTE +1
|
|
345
|
+
// a ogni restart del child e al reconnect presenta sempre la propria generation
|
|
346
|
+
// corrente. La transizione attesa e' quindi `=== current` (stesso restart,
|
|
347
|
+
// retry/reattach) oppure `=== current + 1` (un restart del supervisore). Un salto
|
|
348
|
+
// avanti arbitrario (es. 0->99) o all'indietro NON e' una transizione che il
|
|
349
|
+
// client onesto produrrebbe: deny. msg.generation assente -> fallback alla
|
|
350
|
+
// current (compat), accettato. Post-restart (entry.lease null) non c'e' generation
|
|
351
|
+
// persistita da validare (contratto rev25 EC6): firma+bound di grace resta il gate.
|
|
352
|
+
if (entry.lease && Number.isInteger(msg.generation)) {
|
|
353
|
+
const cur = entry.lease.generation;
|
|
354
|
+
if (msg.generation !== cur && msg.generation !== cur + 1) {
|
|
355
|
+
return denyReconnect(socket);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
// R3.3.5/GC1 (rev27): oltre la grace il reconnect e' rifiutato. Con entry.lease
|
|
359
|
+
// vivo ci pensa reattach (null su grace scaduta). Post-restart (lease null) usiamo
|
|
360
|
+
// il bound di grace persistito PER CELLA, ora SEMPRE valorizzato: se la richiesta
|
|
361
|
+
// arriva ALLA deadline o oltre, rifiuta (>= allineato a cell-lease.js).
|
|
362
|
+
// Recovery post-restart con supervisore/child vivi solo ENTRO il bound live.
|
|
363
|
+
if (!entry.lease && now() >= entry.graceDeadline) {
|
|
364
|
+
return denyReconnect(socket);
|
|
365
|
+
}
|
|
366
|
+
const base = entry.lease || L.openLease({ cellId, launchEpoch: entry.launchEpoch, generation, leaseId: L.newLeaseId(), now: now() });
|
|
367
|
+
const reattached = entry.lease
|
|
368
|
+
? L.reattach(entry.lease, { leaseId: L.newLeaseId(), generation, now: now() })
|
|
369
|
+
: base;
|
|
370
|
+
if (!reattached) return denyReconnect(socket);
|
|
371
|
+
// R3.3.4: lease NUOVO (leaseId nuovo), stessa identita'. Associa la nuova connessione.
|
|
372
|
+
if (!bindLiveSocket(cellId, entry, socket, { lease: reattached })) {
|
|
373
|
+
return denyReconnect(socket);
|
|
374
|
+
}
|
|
375
|
+
log(`cell-lease: ${cellId} reconnect ok (lease ${reattached.leaseId}, verifier ${out.keyId})`); // C6: chiave osservabile
|
|
376
|
+
writeSafe(socket, { type: 'lease', leaseId: reattached.leaseId, proof: issueLeaseProof(cellId, entry, reattached) });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// --- API pubblica ---
|
|
380
|
+
|
|
381
|
+
// Apre l'endpoint stabile UDS 0o600 per una cella (R3.3.2), riusando l'identity
|
|
382
|
+
// e lo stablePath gia' noti nell'entry. Idempotente: se l'entry ha gia' uno
|
|
383
|
+
// stableServer vivo non fa nulla. Condiviso da track() (prima apertura) e da
|
|
384
|
+
// boot() (riapertura dopo restart per le celle persistite).
|
|
385
|
+
async function openEndpoint(cellId, entry) {
|
|
386
|
+
if (entry.stableServer) return entry.stablePath;
|
|
387
|
+
ensureRuntimeDir(dir);
|
|
388
|
+
const sp = entry.stablePath || stablePathFor(cellId);
|
|
389
|
+
try { fsImpl.unlinkSync(sp); } catch (_) {}
|
|
390
|
+
const server = netImpl.createServer((sock) => onStableConnection(cellId, sock));
|
|
391
|
+
await new Promise((resolve, reject) => {
|
|
392
|
+
server.once('error', reject);
|
|
393
|
+
// F-C (audit 2a @ 142e272): niente piu' set/restore di process.umask.
|
|
394
|
+
// process.umask e' GLOBALE e la coppia set/restore non e' atomica: sotto
|
|
395
|
+
// openEndpoint concorrenti il restore di uno ripristinava lo 0o177 appena
|
|
396
|
+
// settato dall'altro e il processo restava driftato. La protezione e'
|
|
397
|
+
// (1) la directory owner-only verificata a OGNI bind (ensureRuntimeDir,
|
|
398
|
+
// subito PRIMA del listen, eseguita non presupposta) e (2) il chmod 0o600
|
|
399
|
+
// forzato qui sotto, che NON ingoia il fallimento.
|
|
400
|
+
server.listen(sp, () => {
|
|
401
|
+
server.removeListener('error', reject);
|
|
402
|
+
server.unref();
|
|
403
|
+
// Il chmod forza/verifica 0o600; il suo fallimento NON va ingoiato (R3.3.2).
|
|
404
|
+
try {
|
|
405
|
+
fsImpl.chmodSync(sp, 0o600);
|
|
406
|
+
} catch (e) {
|
|
407
|
+
try { server.close(); } catch (_) {}
|
|
408
|
+
try { fsImpl.unlinkSync(sp); } catch (_) {}
|
|
409
|
+
reject(e);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
resolve();
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
entry.stablePath = sp;
|
|
416
|
+
entry.stableServer = server;
|
|
417
|
+
return sp;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// F-C (audit 2a @ 142e272): track() serializzato PER CELLA. Due track
|
|
421
|
+
// simultanei sulla stessa cella vedevano entrambi cells.get() vuoto, generavano
|
|
422
|
+
// due identity divergenti e il perdente restava con un'identity inutile. La
|
|
423
|
+
// catena per cellId fa attendere al secondo la fine del primo; poi
|
|
424
|
+
// l'idempotenza esistente fa riusare la STESSA identity.
|
|
425
|
+
const trackGates = new Map(); // cellId -> Promise<void> (completamento del turno)
|
|
426
|
+
function track(cellId) {
|
|
427
|
+
const prev = trackGates.get(cellId) || Promise.resolve();
|
|
428
|
+
const run = prev.then(() => trackSerialized(cellId));
|
|
429
|
+
const next = run.then(() => {}, () => {});
|
|
430
|
+
trackGates.set(cellId, next);
|
|
431
|
+
next.then(() => { if (trackGates.get(cellId) === next) trackGates.delete(cellId); });
|
|
432
|
+
return run;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Apertura lato server in up(): genera launchEpoch, persiste PER CELLA, apre
|
|
436
|
+
// l'endpoint stabile UDS 0o600. Ritorna i dati da inserire nel payload.
|
|
437
|
+
// 2b: nessuna capability nel ritorno — il proof supervisore arriva sul canale
|
|
438
|
+
// lease all'attach, non nel payload (il detentore lo riceve dal server).
|
|
439
|
+
// Chiamata SOLO tramite track() qui sopra (serializzazione per cella).
|
|
440
|
+
async function trackSerialized(cellId) {
|
|
441
|
+
ensureRuntimeDir(dir);
|
|
442
|
+
// Rispetta un'identity gia' nota (in memoria o persistita): al restart del
|
|
443
|
+
// server NON si genera una nuova launchEpoch, perche' il supervisore vivo
|
|
444
|
+
// reconnecta con quella originale (e il suo proof la porta firmata).
|
|
445
|
+
const existing = cells.get(cellId);
|
|
446
|
+
// P1-2 (reaudit dd38c83): snapshot dell'entry preesistente prima di mutare
|
|
447
|
+
// qualunque cosa. Se persistEntry fallisce su una cella gia' viva, il cleanup
|
|
448
|
+
// deve ripristinare lo stato precedente, NON cancellarlo.
|
|
449
|
+
const hadExisting = !!existing;
|
|
450
|
+
const existingSnapshot = existing ? {
|
|
451
|
+
launchEpoch: existing.launchEpoch,
|
|
452
|
+
stablePath: existing.stablePath, stableServer: existing.stableServer,
|
|
453
|
+
lease: existing.lease, socket: existing.socket, graceTimer: existing.graceTimer,
|
|
454
|
+
graceDeadline: existing.graceDeadline,
|
|
455
|
+
} : null;
|
|
456
|
+
// openEndpoint puo' aver creato un nuovo stableServer se l'entry non ne aveva uno.
|
|
457
|
+
const serverBeforeOpen = existing ? existing.stableServer : null;
|
|
458
|
+
// F-A (audit 2a): il SECONDO INGRESSO valida come il primo. Un valore
|
|
459
|
+
// persistito e' riusato SOLO se ha il formato che il runtime produce
|
|
460
|
+
// (hex-16 l'epoch); malformato -> identity NUOVA generata, e la persistEntry
|
|
461
|
+
// qui sotto la scrive, riparando la entry corrotta sul disco.
|
|
462
|
+
let persistedEpoch = null;
|
|
463
|
+
try {
|
|
464
|
+
const persisted = readPersistedCell(cellId);
|
|
465
|
+
if (persisted && typeof persisted.launchEpoch === 'string' && EPOCH_RE.test(persisted.launchEpoch)) {
|
|
466
|
+
persistedEpoch = persisted.launchEpoch;
|
|
467
|
+
}
|
|
468
|
+
} catch (e) {
|
|
469
|
+
log(`cell-lease: read per-cell failed per ${cellId}: ${e && e.message}`);
|
|
470
|
+
}
|
|
471
|
+
const launchEpoch = (existing && existing.launchEpoch)
|
|
472
|
+
|| persistedEpoch
|
|
473
|
+
|| crypto.randomBytes(8).toString('hex');
|
|
474
|
+
const entry = existing
|
|
475
|
+
|| { launchEpoch, stablePath: stablePathFor(cellId), stableServer: null, lease: null, socket: null, graceTimer: null, graceDeadline: now() + L.GRACE_MS };
|
|
476
|
+
entry.launchEpoch = launchEpoch;
|
|
477
|
+
if (!entry.stablePath) entry.stablePath = stablePathFor(cellId);
|
|
478
|
+
// Idempotente: se l'endpoint e' gia' aperto (es. boot() poi up()) non lo ricrea.
|
|
479
|
+
await openEndpoint(cellId, entry);
|
|
480
|
+
cells.set(cellId, entry);
|
|
481
|
+
if (!persistEntry(cellId, entry)) {
|
|
482
|
+
// P1-2b: il record durevole (identity) e' essenziale per la recovery post-restart.
|
|
483
|
+
// P1-2 (reaudit dd38c83): se la cella era GIA' viva, NON cancellarla — ripristina
|
|
484
|
+
// lo stato precedente. Solo le risorse create in QUESTO tentativo vengono pulite.
|
|
485
|
+
if (hadExisting) {
|
|
486
|
+
entry.launchEpoch = existingSnapshot.launchEpoch;
|
|
487
|
+
entry.lease = existingSnapshot.lease;
|
|
488
|
+
entry.socket = existingSnapshot.socket;
|
|
489
|
+
entry.graceTimer = existingSnapshot.graceTimer;
|
|
490
|
+
entry.graceDeadline = existingSnapshot.graceDeadline;
|
|
491
|
+
if (entry.stableServer && entry.stableServer !== serverBeforeOpen) {
|
|
492
|
+
try { entry.stableServer.close(); } catch (_) {}
|
|
493
|
+
entry.stableServer = serverBeforeOpen;
|
|
494
|
+
entry.stablePath = existingSnapshot.stablePath;
|
|
495
|
+
}
|
|
496
|
+
cells.set(cellId, entry);
|
|
497
|
+
} else {
|
|
498
|
+
// Cella nuova: il tentativo e' fallito, cleanup completo e' sicuro
|
|
499
|
+
cells.delete(cellId);
|
|
500
|
+
if (entry.stableServer) { try { entry.stableServer.close(); } catch (_) {} try { fsImpl.unlinkSync(entry.stablePath); } catch (_) {} }
|
|
501
|
+
}
|
|
502
|
+
throw new Error(`cell-lease: track(${cellId}) non persistito: store illeggibile`);
|
|
503
|
+
}
|
|
504
|
+
return { stablePath: entry.stablePath, launchEpoch };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Recovery di produzione (R3.3.1 fail-closed): al boot del server la map e'
|
|
508
|
+
// vuota e nessun lease sopravvive. Ricarichiamo {launchEpoch, graceDeadline}
|
|
509
|
+
// persistiti PER CELLA e RIAPRIAMO l'endpoint stabile per ogni cella nota,
|
|
510
|
+
// cosicche' un supervisore vivo che reconnecta dopo il restart trovi
|
|
511
|
+
// l'endpoint. La verifica del proof non richiede stato ricostruito: la chiave
|
|
512
|
+
// verifier e' persistita per-installazione.
|
|
513
|
+
async function boot() {
|
|
514
|
+
loadPersisted();
|
|
515
|
+
for (const [cellId, entry] of cells) {
|
|
516
|
+
if (entry.stableServer) continue;
|
|
517
|
+
try {
|
|
518
|
+
await openEndpoint(cellId, entry);
|
|
519
|
+
} catch (e) {
|
|
520
|
+
log(`cell-lease: boot endpoint failed for ${cellId}: ${e && e.message}`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Connessione broker one-shot iniziale (R3.1.1): autenticata dal nonce del
|
|
526
|
+
// broker; qui associiamo la connessione al lease (primo contatto).
|
|
527
|
+
// 2b: attachInitial NON scrive nulla sul canale. Durante la consegna del
|
|
528
|
+
// payload il canale appartiene al protocollo del broker (frame length-
|
|
529
|
+
// prefixed u32): una riga JSON scritta prima del payload corromperebbe la
|
|
530
|
+
// lettura di receivePayload e il child non nascerebbe — misurato dal gate
|
|
531
|
+
// (lease-audit-2a-fixes, F-B positivo). Il proof supervisore arriva con
|
|
532
|
+
// l'ACK del primo refresh, che il lease-client invia IMMEDIATAMENTE
|
|
533
|
+
// all'avvio: la finestra attach->primo-ack senza proof detenuto e' fail-closed
|
|
534
|
+
// (reconnect negato -> grace -> onLost), coerente col modello per cui nessun
|
|
535
|
+
// commit persistito = nessuna recovery promessa.
|
|
536
|
+
function attachInitial(cellId, socket, { generation = 0 } = {}) {
|
|
537
|
+
const entry = cells.get(cellId);
|
|
538
|
+
if (!entry) return false;
|
|
539
|
+
const lease = L.openLease({ cellId, launchEpoch: entry.launchEpoch, generation, leaseId: L.newLeaseId(), now: now() });
|
|
540
|
+
if (!lease) return false;
|
|
541
|
+
return bindLiveSocket(cellId, entry, socket, { lease });
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Al boot del server (R3.3.1 fail-closed): la map e' vuota. Ricarichiamo solo
|
|
545
|
+
// {launchEpoch, graceDeadline} persistiti PER CELLA (D1), per riconoscere le
|
|
546
|
+
// identity (il proof le porta firmate) e per rifiutare reconnect oltre la
|
|
547
|
+
// grace (R3.3.5). Nessun lease/eligibilita' sopravvive.
|
|
548
|
+
// E3: un file malformato/corrotto salta la PROPRIA cella (logged); le altre
|
|
549
|
+
// caricano. Con lo store unico di 2a un parse error buttava tutte.
|
|
550
|
+
function loadPersisted() {
|
|
551
|
+
let files;
|
|
552
|
+
try {
|
|
553
|
+
files = fsImpl.readdirSync(stateDir);
|
|
554
|
+
} catch (e) {
|
|
555
|
+
if (e && e.code !== 'ENOENT') log(`cell-lease: loadPersisted readdir fallita, nessuna cella recuperata: ${e && e.message}`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
for (const file of files) {
|
|
559
|
+
if (!file.endsWith('.json')) continue;
|
|
560
|
+
const cellId = file.slice(0, -'.json'.length);
|
|
561
|
+
if (cells.has(cellId)) continue;
|
|
562
|
+
let info;
|
|
563
|
+
try {
|
|
564
|
+
info = readPersistedCell(cellId);
|
|
565
|
+
} catch (e) {
|
|
566
|
+
log(`cell-lease: loadPersisted entry '${cellId}' illeggibile, saltata: ${e && e.message}`);
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
// P1-4 (reaudit dd38c83): forma <> semantica. Il runtime produce launchEpoch
|
|
570
|
+
// come hex di 16 char. Fail-closed: formato errato = entry saltata.
|
|
571
|
+
if (!info || typeof info.launchEpoch !== 'string' || !EPOCH_RE.test(info.launchEpoch)) {
|
|
572
|
+
log(`cell-lease: loadPersisted entry '${cellId}' malformata, saltata`);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
// L'endpoint stabile viene (ri)aperto da boot()/track(); qui registriamo
|
|
576
|
+
// solo l'identita' nota + il bound di grace, cosicche' handleReconnect possa
|
|
577
|
+
// validarli anche quando la cella non sia ancora passata di nuovo per track().
|
|
578
|
+
cells.set(cellId, {
|
|
579
|
+
launchEpoch: info.launchEpoch,
|
|
580
|
+
// GC1.6: graceDeadline assente/illeggibile/non-intero = grace gia' scaduta (0 -> deny sempre).
|
|
581
|
+
// P1-4: forma <> semantica — un bound oltre now()+2*GRACE_MS non e' producibile
|
|
582
|
+
// da questa fetta: trattato come scaduto (0). Tolleranza 2*GRACE_MS per un
|
|
583
|
+
// restart durante la grace. Fail-closed: bound assurdo = scaduto.
|
|
584
|
+
graceDeadline: (Number.isInteger(info.graceDeadline) && info.graceDeadline > 0
|
|
585
|
+
&& info.graceDeadline <= now() + 2 * L.GRACE_MS) ? info.graceDeadline : 0,
|
|
586
|
+
stablePath: stablePathFor(cellId), stableServer: null, lease: null, socket: null, graceTimer: null,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// --- superficie child (B5): register / refresh / recovery -------------------
|
|
592
|
+
//
|
|
593
|
+
// Tre metodi DISTINTI perche' i loro valori di ritorno non possono mentire
|
|
594
|
+
// l'uno con l'altro: register puo' rispondere 'pending' (la cella non e'
|
|
595
|
+
// ancora tracciata); refresh su una registration viva risponde 'live' con un
|
|
596
|
+
// proof nuovo e NON ha stati pendenti; recovery riprende un'incarnazione con
|
|
597
|
+
// un proof scaduto da poco. Un metodo unico costringerebbe 'refresh' a un
|
|
598
|
+
// valore di ritorno che mente in uno dei due casi.
|
|
599
|
+
//
|
|
600
|
+
// B2: incarnationId e' PER-REGISTRATION, mai globale — il register di una
|
|
601
|
+
// cella crea la propria incarnazione e non tocca le altre. Le registration
|
|
602
|
+
// vivono in memoria e NON sopravvivono al restart del processo (coerente col
|
|
603
|
+
// fail-closed R3.3.1 del lease): dopo un restart del server il child
|
|
604
|
+
// re-registra (nuova incarnazione).
|
|
605
|
+
//
|
|
606
|
+
// B3: l'unita' dell'attempt e' la PRESENTAZIONE di recovery, non la
|
|
607
|
+
// connessione — una connessione puo' portare piu' presentazioni, e contare
|
|
608
|
+
// connessioni conterebbe la cosa sbagliata. Ogni presentazione (anche negata)
|
|
609
|
+
// consuma un attempt della propria registration; oltre il cap la
|
|
610
|
+
// registration e' chiusa e serve un register nuovo.
|
|
611
|
+
const childRegs = new Map(); // cellId -> { incarnationId, createdAt, lastAt, recoveryAttempts }
|
|
612
|
+
const RECOVERY_ATTEMPT_CAP = 8;
|
|
613
|
+
// Finestre del child, derivate dalle misure del lease:
|
|
614
|
+
// - un proof vive PROOF_TTL_MS (B8: 60s) dall'ultimo refresh;
|
|
615
|
+
// - il recovery accetta un proof scaduto da meno di una grace (L.GRACE_MS):
|
|
616
|
+
// il detentore ha saltato i refresh, non e' stato sostituito;
|
|
617
|
+
// - quindi la registration e' viva fino a lastAt + PROOF_TTL_MS + GRACE_MS.
|
|
618
|
+
// Senza il margine di grace la finestra di recovery sarebbe VUOTA: la
|
|
619
|
+
// registration morirebbe esattamente col proof che dovrebbe riprendere.
|
|
620
|
+
const CHILD_REG_WINDOW_MS = PROOF_TTL_MS + L.GRACE_MS;
|
|
621
|
+
const CHILD_PROOF_GRACE_MS = L.GRACE_MS;
|
|
622
|
+
|
|
623
|
+
function issueChildProof(cellId, reg) {
|
|
624
|
+
return signProof(ensureVerifier(), {
|
|
625
|
+
kind: 'child',
|
|
626
|
+
cellId,
|
|
627
|
+
incarnationId: reg.incarnationId,
|
|
628
|
+
jti: crypto.randomBytes(8).toString('hex'),
|
|
629
|
+
issuedAt: now(),
|
|
630
|
+
}, { now });
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function childRegister(cellId) {
|
|
634
|
+
if (!validCellId(cellId)) return { status: 'denied', reason: 'cellId' };
|
|
635
|
+
if (!cells.has(cellId)) {
|
|
636
|
+
// La cella non e' (ancora) tracciata dal lease del supervisore: il join e'
|
|
637
|
+
// pendente. Solo register puo' rispondere cosi' (B5).
|
|
638
|
+
return { status: 'pending', retryAfterMs: L.REFRESH_MS };
|
|
639
|
+
}
|
|
640
|
+
const reg = {
|
|
641
|
+
incarnationId: crypto.randomBytes(8).toString('hex'), // B2: per-registration
|
|
642
|
+
createdAt: now(),
|
|
643
|
+
lastAt: now(),
|
|
644
|
+
recoveryAttempts: 0,
|
|
645
|
+
};
|
|
646
|
+
childRegs.set(cellId, reg);
|
|
647
|
+
return { status: 'registered', incarnationId: reg.incarnationId, proof: issueChildProof(cellId, reg) };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function childRefresh(cellId, proof) {
|
|
651
|
+
const reg = childRegs.get(cellId);
|
|
652
|
+
if (!reg) return { status: 'no-registration' };
|
|
653
|
+
const out = verifyProof(liveKeys(), proof, {
|
|
654
|
+
now,
|
|
655
|
+
expect: { kind: 'child', cellId, incarnationId: reg.incarnationId },
|
|
656
|
+
});
|
|
657
|
+
if (!out.ok) return { status: 'denied', reason: out.reason };
|
|
658
|
+
if (now() >= reg.lastAt + CHILD_REG_WINDOW_MS) return { status: 'expired' };
|
|
659
|
+
reg.lastAt = now();
|
|
660
|
+
return { status: 'live', incarnationId: reg.incarnationId, proof: issueChildProof(cellId, reg) };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function childRecovery(cellId, proof) {
|
|
664
|
+
const reg = childRegs.get(cellId);
|
|
665
|
+
if (!reg) return { status: 'no-registration' };
|
|
666
|
+
// B3: la presentazione conta PRIMA dell'esito — anche un tentativo negato
|
|
667
|
+
// consuma un attempt della registration.
|
|
668
|
+
reg.recoveryAttempts += 1;
|
|
669
|
+
if (reg.recoveryAttempts > RECOVERY_ATTEMPT_CAP) {
|
|
670
|
+
return { status: 'denied', reason: 'attempt-bound' };
|
|
671
|
+
}
|
|
672
|
+
// Recovery (ResumeFirst): la firma dev'essere valida e i claims della
|
|
673
|
+
// STESSA incarnazione; la scadenza e' tollerata entro una finestra di grace
|
|
674
|
+
// (il proof e' morto per un gap di refresh, non per sostituzione). Oltre
|
|
675
|
+
// quella finestra il proof non e' piu' presentabile NEanche in recovery:
|
|
676
|
+
// per il child l'esito utile e' «expired, serve register», non un deny.
|
|
677
|
+
const out = verifyProof(liveKeys(), proof, {
|
|
678
|
+
now,
|
|
679
|
+
expect: { kind: 'child', cellId, incarnationId: reg.incarnationId },
|
|
680
|
+
graceMs: CHILD_PROOF_GRACE_MS,
|
|
681
|
+
});
|
|
682
|
+
if (!out.ok) {
|
|
683
|
+
if (out.reason === 'expired') return { status: 'expired' };
|
|
684
|
+
return { status: 'denied', reason: out.reason };
|
|
685
|
+
}
|
|
686
|
+
if (now() >= reg.lastAt + CHILD_REG_WINDOW_MS) return { status: 'expired' };
|
|
687
|
+
// Riprende la STESSA incarnazione (non e' una re-registrazione) e consegna
|
|
688
|
+
// un proof fresco: la registration torna viva.
|
|
689
|
+
reg.lastAt = now();
|
|
690
|
+
return { status: 'live', incarnationId: reg.incarnationId, proof: issueChildProof(cellId, reg) };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function status(cellId) {
|
|
694
|
+
const entry = cells.get(cellId);
|
|
695
|
+
if (!entry || !entry.lease) return { cellId, state: 'none' };
|
|
696
|
+
const n = now();
|
|
697
|
+
if (L.isLive(entry.lease)) return { cellId, state: 'live', leaseId: entry.lease.leaseId, generation: entry.lease.generation };
|
|
698
|
+
if (L.isGrace(entry.lease, { now: n })) return { cellId, state: 'grace', leaseId: entry.lease.leaseId, graceDeadline: entry.lease.graceDeadline };
|
|
699
|
+
if (L.isExpired(entry.lease, { now: n })) return { cellId, state: 'expired', leaseId: entry.lease.leaseId };
|
|
700
|
+
return { cellId, state: 'none' };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function close() {
|
|
704
|
+
for (const [cellId, entry] of cells) {
|
|
705
|
+
clearGraceTimer(entry);
|
|
706
|
+
detachSocket(entry);
|
|
707
|
+
if (entry.stableServer) { try { entry.stableServer.close(); } catch (_) {} }
|
|
708
|
+
try { if (entry.stablePath) fsImpl.unlinkSync(entry.stablePath); } catch (_) {}
|
|
709
|
+
}
|
|
710
|
+
cells.clear();
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
return {
|
|
714
|
+
boot, track, attachInitial, loadPersisted, status, close, _cells: cells,
|
|
715
|
+
childRegister, childRefresh, childRecovery,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
module.exports = { createLeaseManager, sanitizeCell };
|