@mmmbuto/nexuscrew 0.9.4 → 0.9.5
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 +83 -0
- package/frontend/dist/assets/index-BUbgwJsU.js +93 -0
- package/frontend/dist/assets/index-BzmGsXBy.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/pidfile.js +389 -16
- package/lib/cli/runtime-lifecycle.js +11 -0
- package/lib/nodes/commands.js +8 -2
- package/lib/nodes/health.js +1 -0
- package/lib/nodes/peer-transitions.js +10 -4
- package/lib/nodes/tunnel-supervisor.js +274 -30
- package/lib/nodes/tunnel.js +112 -0
- package/lib/proxy/panel-auth.js +45 -5
- package/lib/settings/pairing-coordinator.js +28 -1
- package/lib/update/runner.js +10 -3
- package/package.json +1 -1
- package/frontend/dist/assets/index-CbMytNmg.css +0 -32
- package/frontend/dist/assets/index-DFI1J-u5.js +0 -93
package/lib/cli/pidfile.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
// Pidfile con verified kill: metadata {pid, cmd, startTs}; kill verifica cmd+pid
|
|
3
|
-
// prima di signalare (no
|
|
3
|
+
// prima di signalare (no broad match by name). Il controllo pid+nascita riduce
|
|
4
|
+
// il PID reuse a una finestra residua fra verifica e segnale — non lo elimina;
|
|
5
|
+
// dettaglio e motivo nel commento sopra il kill in killPidfile. [R1]
|
|
4
6
|
// Primario su Termux (serve --pidfile); opzionale --manual su linux/mac.
|
|
5
7
|
const fs = require('node:fs');
|
|
6
8
|
const os = require('node:os');
|
|
@@ -26,33 +28,265 @@ function currentUid() {
|
|
|
26
28
|
// `/proc/<pid>/stat` field 22 is the kernel start tick. Unlike a PID or an
|
|
27
29
|
// argv it cannot be recreated by a later process. macOS has no /proc, so a
|
|
28
30
|
// conservative `ps lstart` fallback still combines with UID, argv and runId.
|
|
29
|
-
|
|
31
|
+
//
|
|
32
|
+
// probeProcessStart espone anche la CAUSA del fallimento (non solo il suo
|
|
33
|
+
// esito): serve a writePidfile per distinguere, alla creazione, «questa
|
|
34
|
+
// piattaforma non sa attestare le nascite» (fatto stabile) da «il tentativo
|
|
35
|
+
// e' fallito su QUESTO pid per un motivo che non sappiamo escludere
|
|
36
|
+
// transitorio» (non un fatto sulla piattaforma). Guardando solo se il
|
|
37
|
+
// risultato e' null le due cose sono indistinguibili — ed e' esattamente
|
|
38
|
+
// l'errore di categoria che questa funzione evita: unsupported si dichiara
|
|
39
|
+
// SOLO quando ENTRAMBI i meccanismi (proc e ps) sono strutturalmente
|
|
40
|
+
// impossibili qui — /proc assente o negato con EACCES/EPERM (una policy di
|
|
41
|
+
// visibilita' di sistema, non un fatto su un pid), E il binario `ps` stesso
|
|
42
|
+
// non si trova (ENOENT sullo spawn, non un suo rifiuto degli argomenti, che
|
|
43
|
+
// potrebbe voler dire tante cose diverse e non le distinguiamo in modo
|
|
44
|
+
// affidabile). Ogni altro fallimento resta indeterminate: onesto, non
|
|
45
|
+
// generoso.
|
|
46
|
+
function probeProcessStart(pid) {
|
|
47
|
+
let procStructurallyBlocked = false;
|
|
30
48
|
try {
|
|
31
49
|
const raw = fs.readFileSync(`/proc/${pid}/stat`, 'utf8').trim();
|
|
32
50
|
const match = raw.match(/^\d+\s+\([^)]*\)\s+(.+)$/);
|
|
33
51
|
const fields = match && match[1].trim().split(/\s+/);
|
|
34
52
|
const ticks = fields && fields[19]; // field 22, after state=field 3
|
|
35
|
-
if (/^\d+$/.test(String(ticks || ''))) return `linux:${ticks}
|
|
36
|
-
} catch (
|
|
53
|
+
if (/^\d+$/.test(String(ticks || ''))) return { value: `linux:${ticks}`, cause: null };
|
|
54
|
+
} catch (e) {
|
|
55
|
+
const denied = e && (e.code === 'EACCES' || e.code === 'EPERM');
|
|
56
|
+
const procMissing = e && e.code === 'ENOENT' && !fs.existsSync('/proc');
|
|
57
|
+
procStructurallyBlocked = Boolean(denied || procMissing);
|
|
58
|
+
}
|
|
59
|
+
let psMissing = false;
|
|
37
60
|
try {
|
|
38
61
|
const text = execFileSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8' }).trim();
|
|
39
|
-
|
|
40
|
-
} catch (
|
|
62
|
+
if (text) return { value: `ps:${text}`, cause: null };
|
|
63
|
+
} catch (e) {
|
|
64
|
+
psMissing = Boolean(e && e.code === 'ENOENT');
|
|
65
|
+
}
|
|
66
|
+
const unsupported = procStructurallyBlocked && psMissing;
|
|
67
|
+
return { value: null, cause: unsupported ? 'unsupported' : 'indeterminate' };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readProcessStart(pid) {
|
|
71
|
+
return probeProcessStart(pid).value;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Campi che SOLO writePidfile puo' scrivere: mai dal chiamante via `extra`.
|
|
75
|
+
// Misurato (Dev, 2026-08-17): con lo spread di extra per ultimo,
|
|
76
|
+
// writePidfile(f, pid, cmd, {processStart:'FINTO', attestation:'unsupported'})
|
|
77
|
+
// vinceva sul valore vero calcolato da probeProcessStart — un chiamante
|
|
78
|
+
// poteva scrivere un'attestazione INVENTATA. safeExtra la filtra qui, e i
|
|
79
|
+
// campi veri sono comunque scritti DOPO nello spread finale: due barriere,
|
|
80
|
+
// non una, perche' un bug nel filtro da solo basterebbe a riaprire il buco.
|
|
81
|
+
const RESERVED_META_FIELDS = new Set(['pid', 'cmd', 'startTs', 'uid', 'processStart', 'attestation']);
|
|
82
|
+
|
|
83
|
+
// Il marker di schema vive ACCANTO al pidfile (stessa directory, es.
|
|
84
|
+
// ~/.nexuscrew/), MAI dentro di esso: il pidfile e' il file sospetto, non e'
|
|
85
|
+
// lui a poter certificare se stesso. La sua presenza e' un evento ONE-WAY per
|
|
86
|
+
// installazione: "codice v2 ha scritto qui almeno una volta", non
|
|
87
|
+
// "l'attestazione e' riuscita" — writePidfile lo crea al primo successo,
|
|
88
|
+
// qualunque sia l'esito della sonda su quello specifico pid (se lo legassi
|
|
89
|
+
// al successo dell'attestazione, su una piattaforma davvero unsupported il
|
|
90
|
+
// marker non arriverebbe MAI e il bypass resterebbe aperto per sempre —
|
|
91
|
+
// proprio dove serve di piu' che si chiuda). Da quel momento in poi
|
|
92
|
+
// (killPidfile, sotto) un pidfile senza nessun campo di attestazione smette
|
|
93
|
+
// di essere spiegabile come "legittimo, pre-migrazione": diventa sospetto
|
|
94
|
+
// (downgrade, restore da backup, corruzione) e si rifiuta.
|
|
95
|
+
//
|
|
96
|
+
// TRE CONFINI DICHIARATI, non dedotti — chi indaga un incidente fra sei mesi
|
|
97
|
+
// deve trovarli qui, non scoprirli:
|
|
98
|
+
// 1. Un restore che riporta indietro l'INTERA directory (~/.nexuscrew/)
|
|
99
|
+
// riporta indietro anche il marker: la finestra di bypass si RIAPRE. Non
|
|
100
|
+
// e' un difetto di questa scelta — non esiste un posto migliore: accanto
|
|
101
|
+
// al binario e' read-only, e l'update lo sovrascrive comunque.
|
|
102
|
+
// 2. Con NEXUSCREW_PIDFILE puntato a una directory nuova il marker non c'e',
|
|
103
|
+
// quindi il bypass e' concesso li'. Nei test e' voluto (isolamento senza
|
|
104
|
+
// un parametro home in piu' da cablare); in produzione vuol dire che chi
|
|
105
|
+
// controlla quella variabile d'ambiente controlla anche la finestra — non
|
|
106
|
+
// un confine di sicurezza reale (chi setta l'ambiente del processo ha gia'
|
|
107
|
+
// vinto), ma va detto: il marker non e' una garanzia piu' forte di questa.
|
|
108
|
+
// 3. R-pidfile-5: claimSchemaMarker (sotto) crea il marker al KILL, non solo
|
|
109
|
+
// al write — quindi un pidfile che sta per essere RIMOSSO puo' lasciare
|
|
110
|
+
// dietro un marker in una directory che magari non ospitera' mai piu' un
|
|
111
|
+
// runtime. E' INNOCUO: il marker non ha altro effetto che rendere PIU'
|
|
112
|
+
// caute le decisioni FUTURE su quella stessa directory (fail-closed per
|
|
113
|
+
// meta senza attestazione, come se la migrazione fosse gia' avvenuta li'
|
|
114
|
+
// — che e' vero: e' avvenuta, anche se il pidfile che l'ha innescata non
|
|
115
|
+
// c'e' piu'). Il verso del residuo e' sempre quello sicuro: mai una
|
|
116
|
+
// concessione in piu', al massimo un rifiuto in un caso limite di riuso
|
|
117
|
+
// di un percorso abbandonato — coerente col confine 1 sopra.
|
|
118
|
+
function schemaMarkerPath(pidfilePath) {
|
|
119
|
+
return path.join(path.dirname(pidfilePath), '.pidfile-schema-v2');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// R-pidfile-4 (2026-08-17, audit su develop@437d29f): misurato dall'auditor
|
|
123
|
+
// sul frozen — se .pidfile-schema-v2 esiste come DIRECTORY (o symlink, o
|
|
124
|
+
// qualunque cosa non sia un file regolare), il vecchio hasSchemaMarker
|
|
125
|
+
// tornava `false` per costruzione (isFile() falso), e ensureSchemaMarker
|
|
126
|
+
// ingoiava OGNI errore della creazione (EEXIST incluso) senza distinguere.
|
|
127
|
+
// Risultato: il marker non nasceva MAI, ogni writePidfile successiva
|
|
128
|
+
// ripeteva lo stesso fallimento silenzioso, e "nessun marker" veniva letto
|
|
129
|
+
// come "pre-migrazione" — concedendo ambiguous-compat PER SEMPRE, non un
|
|
130
|
+
// giro in piu'. L'errore di fondo: la scrittura e' best-effort per buoni
|
|
131
|
+
// motivi (non deve mai far fallire writePidfile), ma un fallimento della
|
|
132
|
+
// scrittura si traduceva in "stato assente", cioe' nella risposta
|
|
133
|
+
// PERMISSIVA. Un meccanismo che non riesce a determinare il proprio stato
|
|
134
|
+
// non deve concedere: deve chiudere. L'incertezza va nella direzione
|
|
135
|
+
// sicura, sempre — lo stesso principio di isAlive/isAttributable in questo
|
|
136
|
+
// file, applicato qui al marker invece che al pid.
|
|
137
|
+
//
|
|
138
|
+
// La decisione al KILL non LEGGE se il marker esiste: PROVA A CREARLO adesso
|
|
139
|
+
// (claimSchemaMarker). Leggerlo soltanto lasciava fuori il caso in cui la
|
|
140
|
+
// scrittura era fallita senza lasciare traccia sul percorso — disco pieno,
|
|
141
|
+
// sola lettura, permesso transitorio: il percorso resta libero, la lettura
|
|
142
|
+
// dice 'assente', e si concedeva per sempre. Una prova fatta ORA ha una
|
|
143
|
+
// risposta verificabile; un evento passato che nessuno ha osservato no.
|
|
144
|
+
// 'created' — non c'era e la creazione riesce adesso: pre-migrazione
|
|
145
|
+
// vera -> ambiguous-compat concesso UNA volta, e da qui
|
|
146
|
+
// in poi il marker esiste per davvero.
|
|
147
|
+
// 'present' — EEXIST su file regolare (mai symlink: lstat, non stat)
|
|
148
|
+
// -> post-migrazione, fail-closed.
|
|
149
|
+
// 'undeterminable' — EEXIST su un tipo non atteso, oppure la creazione
|
|
150
|
+
// fallisce ADESSO per qualunque motivo (ostacolo,
|
|
151
|
+
// ENOSPC, EROFS, permessi, race) -> fail-closed, con il
|
|
152
|
+
// motivo che nomina la causa. Il fallimento della
|
|
153
|
+
// scrittura non e' un effetto collaterale da temere: e'
|
|
154
|
+
// il segnale che produce la risposta sicura.
|
|
155
|
+
// `checkSchemaMarker` resta come lettura pura per diagnostica e test, ma non
|
|
156
|
+
// guida piu' nessuna decisione di sicurezza.
|
|
157
|
+
//
|
|
158
|
+
// COSTO DICHIARATO: su un'installazione nuova con un ostacolo gia' presente
|
|
159
|
+
// su quel percorso, la PRIMA fermata dopo l'aggiornamento fallisce invece di
|
|
160
|
+
// essere concessa (nessun modo di distinguere "ostacolo innocente" da
|
|
161
|
+
// "ostacolo messo li' apposta" — e non e' compito di questa funzione
|
|
162
|
+
// indovinarlo). E' il verso giusto: un errore che dice cosa succede e' meglio
|
|
163
|
+
// di un bypass che non lo dice — ma va scritto qui, non scoperto da chi
|
|
164
|
+
// indaga un incidente.
|
|
165
|
+
function checkSchemaMarker(pidfilePath) {
|
|
166
|
+
const markerPath = schemaMarkerPath(pidfilePath);
|
|
167
|
+
let st;
|
|
168
|
+
try {
|
|
169
|
+
st = fs.lstatSync(markerPath);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
if (e && e.code === 'ENOENT') return { state: 'absent' };
|
|
172
|
+
return { state: 'undeterminable', reason: `marker path not readable (${(e && e.code) || 'unknown error'})` };
|
|
173
|
+
}
|
|
174
|
+
if (st.isSymbolicLink()) return { state: 'undeterminable', reason: 'marker path is a symlink, not a regular file' };
|
|
175
|
+
if (!st.isFile()) return { state: 'undeterminable', reason: 'marker path occupied by a non-regular-file' };
|
|
176
|
+
return { state: 'present' };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Wrapper booleano SOLO per lettura passiva (test, diagnostica esterna) —
|
|
180
|
+
// non guida piu' la decisione di sicurezza: vedi claimSchemaMarker sotto,
|
|
181
|
+
// che e' cosa killPidfile usa davvero. SOLO 'present' e' true.
|
|
182
|
+
// 'undeterminable' NON e' 'absent' — tornerebbe a leggere l'incertezza come
|
|
183
|
+
// permissiva, l'errore del giro precedente.
|
|
184
|
+
function hasSchemaMarker(pidfilePath) {
|
|
185
|
+
return checkSchemaMarker(pidfilePath).state === 'present';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Best-effort per costruzione: EEXIST (un altro processo ha appena scritto
|
|
189
|
+
// lo stesso marker, avvii concorrenti allo start) e' il caso NORMALE di un
|
|
190
|
+
// evento one-way, non un errore. Qualunque ALTRO fallimento (un ostacolo al
|
|
191
|
+
// path, permessi, disco pieno) non deve MAI far fallire writePidfile — il
|
|
192
|
+
// pidfile e' gia' stato scritto quando arriviamo qui, ed e' lui
|
|
193
|
+
// l'informazione che conta.
|
|
194
|
+
//
|
|
195
|
+
// R-pidfile-5 (2026-08-17, audit su develop@fa8bd90): questa funzione NON
|
|
196
|
+
// garantisce piu' nulla sul futuro — prima il commento qui diceva "il
|
|
197
|
+
// prossimo killPidfile lo scoprira' da solo", ed era FALSO: se questa
|
|
198
|
+
// scrittura fallisce con ENOSPC/EROFS/quota/permesso transitorio, non resta
|
|
199
|
+
// NESSUNA traccia sul filesystem (il path resta ENOENT, esattamente come se
|
|
200
|
+
// non si fosse mai tentato) — una garanzia dichiarata che il codice non
|
|
201
|
+
// aveva. La chiusura vera non dipende da questa funzione: killPidfile (vedi
|
|
202
|
+
// claimSchemaMarker) non si fida di un evento passato non osservabile,
|
|
203
|
+
// riprova la creazione ADESSO, nell'istante in cui la decisione conta. Se
|
|
204
|
+
// funziona ancora qui (il caso comune: nessun ostacolo, disco con spazio) e'
|
|
205
|
+
// solo un'ottimizzazione — anticipa la migrazione al primo write invece di
|
|
206
|
+
// aspettare il primo kill su un meta ambiguo — non la fonte della garanzia.
|
|
207
|
+
function ensureSchemaMarker(pidfilePath) {
|
|
208
|
+
try {
|
|
209
|
+
fs.writeFileSync(schemaMarkerPath(pidfilePath), `${JSON.stringify({ since: Date.now() })}\n`,
|
|
210
|
+
{ flag: 'wx', mode: 0o600 });
|
|
211
|
+
} catch (e) {
|
|
212
|
+
if (e && e.code === 'EEXIST') return; // normale: gia' scritto da un altro avvio
|
|
213
|
+
// Qualunque altro fallimento: mai propagare. Non promettiamo che qualcun
|
|
214
|
+
// altro se ne accorgera' — vedi claimSchemaMarker per la vera garanzia.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// R-pidfile-5: LA decisione di sicurezza. checkSchemaMarker (sopra) guarda
|
|
219
|
+
// un EVENTO PASSATO — "il marker e' stato scritto" — che puo' fallire in
|
|
220
|
+
// silenzio senza lasciare traccia (vedi ensureSchemaMarker): il percorso
|
|
221
|
+
// resta ENOENT, indistinguibile da "mai tentato". claimSchemaMarker sposta
|
|
222
|
+
// la domanda da "e' successo in passato?" a "posso ADESSO?" — verificabile
|
|
223
|
+
// nell'istante in cui la decisione conta, non dedotta da una scrittura che
|
|
224
|
+
// potrebbe essere fallita senza lasciare traccia:
|
|
225
|
+
// 'created' — non c'era, la creazione ORA riesce: il percorso era
|
|
226
|
+
// davvero libero, non solo "sembrava" (perche' una
|
|
227
|
+
// scrittura precedente era fallita in silenzio).
|
|
228
|
+
// Pre-migrazione per davvero — e ORA il marker esiste
|
|
229
|
+
// per davvero: i prossimi kill lo vedranno 'present'.
|
|
230
|
+
// 'present' — EEXIST su un file regolare: gia' migrato (invariato).
|
|
231
|
+
// 'undeterminable' — EEXIST su un simlink/non-file, O la creazione
|
|
232
|
+
// fallisce ADESSO per qualunque altro motivo (ENOSPC,
|
|
233
|
+
// EROFS, EACCES/EPERM, EISDIR, una race): non sappiamo
|
|
234
|
+
// perche' in generale, ma sappiamo che ORA non si puo'
|
|
235
|
+
// scrivere qui — ed e' esattamente la domanda che
|
|
236
|
+
// conta, verificata nel momento giusto invece che
|
|
237
|
+
// dedotta da un momento passato che potrebbe mentire.
|
|
238
|
+
//
|
|
239
|
+
// killPidfile diventa, in questo solo ramo, una funzione che SCRIVE (crea un
|
|
240
|
+
// file nuovo), non solo rimuove: valutato deliberatamente, non per svista.
|
|
241
|
+
// Ogni chiamante che puo' leggere un pidfile in questa directory ha gia',
|
|
242
|
+
// nella prassi di questo prodotto, permesso di scrittura sulla STESSA
|
|
243
|
+
// directory (e' li' che pidfile.js scrive di suo — ~/.nexuscrew/ o
|
|
244
|
+
// l'override NEXUSCREW_PIDFILE): non attraversa un confine di permessi che
|
|
245
|
+
// non fosse gia' presupposto per arrivare a chiamare killPidfile affatto.
|
|
246
|
+
// Se la scrittura genuinamente non e' permessa o e' pericolosa li' (EROFS,
|
|
247
|
+
// policy), il ramo 'undeterminable' rifiuta — la scrittura fallita e'
|
|
248
|
+
// proprio il segnale che produce la risposta sicura, non un effetto
|
|
249
|
+
// collaterale da temere.
|
|
250
|
+
function claimSchemaMarker(pidfilePath) {
|
|
251
|
+
const markerPath = schemaMarkerPath(pidfilePath);
|
|
252
|
+
try {
|
|
253
|
+
fs.writeFileSync(markerPath, `${JSON.stringify({ since: Date.now() })}\n`, { flag: 'wx', mode: 0o600 });
|
|
254
|
+
return { state: 'created' };
|
|
255
|
+
} catch (e) {
|
|
256
|
+
if (e && e.code === 'EEXIST') {
|
|
257
|
+
let st;
|
|
258
|
+
try { st = fs.lstatSync(markerPath); }
|
|
259
|
+
catch (e2) { return { state: 'undeterminable', reason: `marker path not readable after EEXIST (${(e2 && e2.code) || 'unknown error'})` }; }
|
|
260
|
+
if (st.isSymbolicLink()) return { state: 'undeterminable', reason: 'marker path is a symlink, not a regular file' };
|
|
261
|
+
if (!st.isFile()) return { state: 'undeterminable', reason: 'marker path occupied by a non-regular-file' };
|
|
262
|
+
return { state: 'present' };
|
|
263
|
+
}
|
|
264
|
+
return { state: 'undeterminable', reason: `marker not creatable now (${(e && e.code) || 'unknown error'})` };
|
|
265
|
+
}
|
|
41
266
|
}
|
|
42
267
|
|
|
43
268
|
// Exclusive create (wx): fallisce se il pidfile esiste già (no overwrite silenzioso).
|
|
269
|
+
// La classificazione (attested/unsupported/indeterminate) si decide QUI, alla
|
|
270
|
+
// creazione, non al kill: e' un fatto su questo tentativo di attestazione,
|
|
271
|
+
// non qualcosa da ricalcolare piu' tardi con lo stesso rischio di confondere
|
|
272
|
+
// "non so attestare" con "non ci sono riuscito adesso" (vedi killPidfile).
|
|
44
273
|
function writePidfile(p, pid, cmd, extra = {}) {
|
|
45
274
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
46
|
-
const
|
|
47
|
-
const
|
|
275
|
+
const rawExtra = extra && typeof extra === 'object' && !Array.isArray(extra) ? extra : {};
|
|
276
|
+
const safeExtra = {};
|
|
277
|
+
for (const key of Object.keys(rawExtra)) {
|
|
278
|
+
if (!RESERVED_META_FIELDS.has(key)) safeExtra[key] = rawExtra[key];
|
|
279
|
+
}
|
|
280
|
+
const { value: processStart, cause } = probeProcessStart(pid);
|
|
48
281
|
const uid = currentUid();
|
|
49
282
|
const meta = JSON.stringify({
|
|
283
|
+
...safeExtra,
|
|
50
284
|
pid, cmd: cmd || '', startTs: Date.now(),
|
|
51
285
|
...(uid === null ? {} : { uid }),
|
|
52
|
-
...(processStart ? { processStart } : {}),
|
|
53
|
-
...safeExtra,
|
|
286
|
+
...(processStart ? { processStart } : { attestation: cause }),
|
|
54
287
|
});
|
|
55
288
|
fs.writeFileSync(p, meta + '\n', { flag: 'wx', mode: 0o600 });
|
|
289
|
+
ensureSchemaMarker(p);
|
|
56
290
|
}
|
|
57
291
|
|
|
58
292
|
// La rimozione NON e' un unlink nudo. Il pidfile e' la prova che un processo
|
|
@@ -186,24 +420,162 @@ function killPidfile(p, signal = 'SIGTERM', impl = {}) {
|
|
|
186
420
|
}
|
|
187
421
|
}
|
|
188
422
|
// PID reuse che il cmd NON vede: due processi con lo stesso comando (o un
|
|
189
|
-
// comando che lo contiene). La nascita li distingue
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
423
|
+
// comando che lo contiene). La nascita li distingue — ma solo AL MOMENTO DI
|
|
424
|
+
// QUESTO CONTROLLO. Se la nascita letta ORA non corrisponde a quella
|
|
425
|
+
// attestata, il proprietario è già morto e il numero è di un altro: qui la
|
|
426
|
+
// garanzia regge, il pidfile è stale e si toglie senza segnalare.
|
|
427
|
+
//
|
|
428
|
+
// Quello che questa verifica NON copre: fra la lettura di liveStart qui e
|
|
429
|
+
// killImpl() poco sotto non c'è atomicità. Se il proprietario muore in
|
|
430
|
+
// quell'istante e il kernel riassegna il pid prima del kill, il segnale
|
|
431
|
+
// parte verso il sostituto — identità confermata un momento fa, non nel
|
|
432
|
+
// momento in cui conta. Questa finestra è intrinseca al modello pid+segnale
|
|
433
|
+
// di POSIX (kill(2) non lega il segnale all'identità appena controllata) e
|
|
434
|
+
// non si chiude qui: la verifica sopra riduce l'esposizione da "sempre" a
|
|
435
|
+
// "l'istante fra questo controllo e il kill", non la elimina. La cura vera
|
|
436
|
+
// è pidfd_open + pidfd_send_signal (il segnale è legato a un file
|
|
437
|
+
// descriptor, non a un numero riassegnabile); Node non la espone senza un
|
|
438
|
+
// addon nativo, e un addon per un prodotto che gira anche su Termux e
|
|
439
|
+
// macOS costa più di quanto renda. Una rilettura dopo il kill non chiude
|
|
440
|
+
// la finestra — è best-effort, non rilevazione certa: il riusato può
|
|
441
|
+
// essere già uscito, e la rilettura corre a sua volta la stessa corsa. Non
|
|
442
|
+
// aggiungerne una come se fosse una guardia.
|
|
443
|
+
//
|
|
444
|
+
// Prima di questo controllo (nascita, aggiunta dopo cmd) il segnale
|
|
445
|
+
// partiva sul solo cmd, senza verifica di identità, per QUALUNQUE pidfile
|
|
446
|
+
// senza processStart — vecchio o nuovo, piattaforma capace o no: era
|
|
447
|
+
// esattamente il buco piu' largo trovato dall'audit (2026-08-17), perche'
|
|
448
|
+
// "nessuna nascita nel meta" copriva sia "questa macchina non sa attestare"
|
|
449
|
+
// sia "l'attestazione e' fallita alla creazione per un motivo che non
|
|
450
|
+
// conosciamo" con lo STESSO comportamento permissivo. Il ramo sotto separa
|
|
451
|
+
// i due casi guardando cosa writePidfile ha DICHIARATO al momento in cui
|
|
452
|
+
// contava (probeProcessStart, sopra), non ricalcolando una sonda adesso —
|
|
453
|
+
// rifarla qui confonderebbe di nuovo "non so attestare" con "non ci sono
|
|
454
|
+
// riuscito in QUESTO istante", l'errore di categoria che l'audit ha
|
|
455
|
+
// contestato nel primo disegno di questo fix.
|
|
456
|
+
let unverifiedBirth = false;
|
|
457
|
+
let schemaMarker = null; // calcolato pigro: solo se serve davvero (vedi sotto)
|
|
193
458
|
if (meta.processStart) {
|
|
194
459
|
const liveStart = (impl.readProcessStartImpl || readProcessStart)(meta.pid);
|
|
195
460
|
if (liveStart && liveStart !== meta.processStart) {
|
|
196
461
|
removePidfile(p, { allowLive: true });
|
|
197
462
|
return { killed: false, reason: 'pid reuse (start mismatch)' };
|
|
198
463
|
}
|
|
464
|
+
// Nascita ATTESTATA ma non rileggibile ora (/proc nascosto, ps assente,
|
|
465
|
+
// permessi): l'identita' non e' verificabile, e questa e' un'operazione
|
|
466
|
+
// DISTRUTTIVA. Il cmd da solo non discrimina — matcha per inclusioni, e
|
|
467
|
+
// due processi con lo stesso comando si somigliano. Si rinuncia: un
|
|
468
|
+
// segnale mancato costa un pidfile stale, un segnale sbagliato uccide il
|
|
469
|
+
// processo di qualcun altro. Il pidfile NON si tocca: non sappiamo se sia
|
|
470
|
+
// stale.
|
|
471
|
+
if (!liveStart) {
|
|
472
|
+
return { killed: false, reason: 'start unverifiable (refusing to signal)' };
|
|
473
|
+
}
|
|
474
|
+
} else if (meta.attestation === 'unsupported') {
|
|
475
|
+
// DICHIARATO alla creazione: ne' /proc ne' `ps` esistevano affatto su
|
|
476
|
+
// questa macchina (probeProcessStart, causa strutturale — non un pid
|
|
477
|
+
// specifico che non rispondeva). Nessun pidfile su questa macchina potra'
|
|
478
|
+
// MAI avere una nascita: qui e' dove un fail-closed spegnerebbe il
|
|
479
|
+
// prodotto proprio dove serve (Termux, primario — vedi testa del file).
|
|
480
|
+
// Si ricade sul criterio storico pid+cmd, ma DICHIARATO nel risultato
|
|
481
|
+
// (unverifiedBirth sotto), non in silenzio.
|
|
482
|
+
//
|
|
483
|
+
// ONESTO NON VUOL DIRE SICURO: su una piattaforma davvero unsupported la
|
|
484
|
+
// proprieta' "mai segnalare un PID solo ereditato" NON E' GARANTITA — un
|
|
485
|
+
// pid morto qui e riassegnato a un processo nostro con cmd compatibile
|
|
486
|
+
// riceve comunque il segnale, perche' senza nascita non c'e' modo di
|
|
487
|
+
// vedere il riassegno. Dichiarare il degrado lo rende TRACCIABILE, non
|
|
488
|
+
// lo chiude. Se un giorno serve la garanzia anche qui, la via non e' un
|
|
489
|
+
// altro probe su PID/cmd (la stessa prova debole con un altro nome): serve
|
|
490
|
+
// una prova ALTERNATIVA del proprietario — per esempio un runId verificato
|
|
491
|
+
// contro un endpoint locale autenticato. Non e' implementata: e' un lavoro
|
|
492
|
+
// a se', con un suo giro di audit (registrato qui, non eseguito).
|
|
493
|
+
unverifiedBirth = 'unsupported';
|
|
494
|
+
} else if (meta.attestation === 'indeterminate') {
|
|
495
|
+
// Il codice NUOVO (questo, o una versione successiva a questo fix) ha
|
|
496
|
+
// PROVATO ad attestare alla creazione e non ci e' riuscito per un motivo
|
|
497
|
+
// che non sa classificare come proprieta' della piattaforma (vedi
|
|
498
|
+
// probeProcessStart sopra) — sa che avrebbe potuto, su questo pid,
|
|
499
|
+
// adesso, e non ce l'ha fatta. Trattarlo come "la piattaforma non sa
|
|
500
|
+
// attestare" sarebbe l'errore di categoria contestato dall'audit:
|
|
501
|
+
// un'assenza non e' una proprieta' stabile solo perche' e' comoda da
|
|
502
|
+
// leggere cosi'. Si rifiuta: il cmd da solo matcha per inclusioni, e non
|
|
503
|
+
// basta a chi non sa nemmeno perche' la nascita manca QUI. Il pidfile
|
|
504
|
+
// NON si tocca, per lo stesso motivo del ramo attestato-ma-illeggibile
|
|
505
|
+
// sopra: non sappiamo se sia stale.
|
|
506
|
+
return { killed: false, reason: 'unattested pidfile (attestation indeterminate: a fresh attempt failed): refusing to signal a pid identified only by cmd' };
|
|
507
|
+
} else {
|
|
508
|
+
// NE' processStart NE' attestation nel meta: non "il codice nuovo ha
|
|
509
|
+
// provato e fallito" (quello e' il ramo indeterminate sopra) — e' un
|
|
510
|
+
// pidfile scritto da un codice che l'attestazione non la conosceva
|
|
511
|
+
// affatto, O un file scritto fuori da writePidfile del tutto (la stessa
|
|
512
|
+
// forma di meta la puo' produrre anche un chiamante non nostro): "nessun
|
|
513
|
+
// campo" da solo non prova la provenienza.
|
|
514
|
+
//
|
|
515
|
+
// R-pidfile-5: la domanda che decide non e' piu' "il marker e' gia'
|
|
516
|
+
// stato scritto?" (un evento passato che puo' fallire in silenzio —
|
|
517
|
+
// vedi ensureSchemaMarker/claimSchemaMarker sopra) ma "posso scriverlo
|
|
518
|
+
// ADESSO?" — claimSchemaMarker prova a crearlo in questo esatto istante:
|
|
519
|
+
// 'created' — non c'era davvero: QUESTA installazione non ha
|
|
520
|
+
// ancora mai completato una scrittura v2. Pre-
|
|
521
|
+
// migrazione per davvero, non solo "sembrava" —
|
|
522
|
+
// compatibilita' ambigua concessa UNA volta, e ORA
|
|
523
|
+
// il marker esiste per davvero: il prossimo kill
|
|
524
|
+
// su questa directory lo vedra' 'present'.
|
|
525
|
+
// 'present' — codice v2 ha gia' scritto qui (o un kill
|
|
526
|
+
// precedente l'ha appena creato): non e' piu'
|
|
527
|
+
// spiegabile come pre-migrazione — sospetto
|
|
528
|
+
// (downgrade, restore parziale del solo pidfile,
|
|
529
|
+
// corruzione). Si rifiuta.
|
|
530
|
+
// 'undeterminable' — la creazione fallisce ADESSO (ostacolo strutturale
|
|
531
|
+
// — directory, symlink — o ENOSPC/EROFS/permesso
|
|
532
|
+
// transitorio/una race): non sappiamo perche' in
|
|
533
|
+
// generale, ma sappiamo che ORA non si puo'
|
|
534
|
+
// scrivere qui, verificato nell'istante in cui
|
|
535
|
+
// conta, non dedotto da uno passato. Si rifiuta.
|
|
536
|
+
//
|
|
537
|
+
// MISURATO (Dev, 2026-08-17): trattare OGNI meta senza attestazione come
|
|
538
|
+
// indeterminate rompe l'aggiornamento automatico per ogni nodo il cui
|
|
539
|
+
// runtime e' ancora precedente a quando processStart e' nato (8fe514f,
|
|
540
|
+
// v0.9.0) — npm install sovrascrive pidfile.js PRIMA che il runner lo
|
|
541
|
+
// richieda, il runner legge il pidfile VECCHIO del runtime in esecuzione,
|
|
542
|
+
// killPidfile rifiuta, stopPortableRuntime torna killed:false,
|
|
543
|
+
// restartRuntime lancia, l'update muore (lib/update/runner.js). Il nodo
|
|
544
|
+
// resta indietro finche' qualcuno non interviene a mano — su un telefono
|
|
545
|
+
// Termux spesso vuol dire mai. Il rischio del ramo pid+cmd (concesso solo
|
|
546
|
+
// su 'created') e' quello che il prodotto ha GIA' in produzione oggi, ne'
|
|
547
|
+
// piu' ne' meno: non lo peggioriamo. Il PID reuse che questo ramo non
|
|
548
|
+
// vede richiede una coincidenza (numero riassegnato E cmd compatibile);
|
|
549
|
+
// il blocco dell'aggiornamento sarebbe invece CERTO per ogni nodo in
|
|
550
|
+
// questo stato, ogni volta. Fra un rischio raro gia' presente e un
|
|
551
|
+
// guasto sicuro introdotto da noi, si sceglie di non introdurre il
|
|
552
|
+
// guasto — decisione di prodotto, non mia.
|
|
553
|
+
//
|
|
554
|
+
// Alternative valutate e scartate: far degradare il runner invece di
|
|
555
|
+
// morire (il difetto gia' visto sul campo che l'ingresso unico di
|
|
556
|
+
// riavvio doveva chiudere); un ramo "legacy" permanente basato sulla sola
|
|
557
|
+
// FORMA del meta (lasciava aperti per sempre restore da backup,
|
|
558
|
+
// corruzione e downgrade — l'auditor l'ha contestato); un marker
|
|
559
|
+
// controllato ma mai riprovato al kill (questo giro — R-pidfile-5:
|
|
560
|
+
// l'auditor ha dimostrato che una scrittura fallita in silenzio riapre
|
|
561
|
+
// lo stesso buco senza lasciare traccia).
|
|
562
|
+
schemaMarker = (impl.claimSchemaMarkerImpl || claimSchemaMarker)(p);
|
|
563
|
+
if (schemaMarker.state === 'created') {
|
|
564
|
+
unverifiedBirth = 'ambiguous-compat';
|
|
565
|
+
} else {
|
|
566
|
+
const causa = schemaMarker.state === 'present' ? 'schema migration' : `marker state undeterminable: ${schemaMarker.reason}`;
|
|
567
|
+
return { killed: false, reason: `unattested pidfile after ${causa}: refusing to signal a pid identified only by cmd` };
|
|
568
|
+
}
|
|
199
569
|
}
|
|
200
570
|
try {
|
|
571
|
+
// Qui si chiude la verifica e si entra nella finestra residua dichiarata
|
|
572
|
+
// sopra: nessuna riverifica fra questo punto e il segnale, per scelta.
|
|
201
573
|
killImpl(meta.pid, signal);
|
|
202
574
|
// allowLive: il segnale e' partito verso il pid VERIFICATO del file (cmd
|
|
203
575
|
// matchato sopra): la rimozione e' giusta anche se il processo non e' ancora
|
|
204
576
|
// sparito da /proc quando unlink gira.
|
|
205
577
|
removePidfile(p, { allowLive: true });
|
|
206
|
-
return { killed: true, pid: meta.pid };
|
|
578
|
+
return { killed: true, pid: meta.pid, ...(unverifiedBirth ? { unverifiedBirth } : {}) };
|
|
207
579
|
} catch (e) {
|
|
208
580
|
return { killed: false, reason: e.message };
|
|
209
581
|
}
|
|
@@ -211,6 +583,7 @@ function killPidfile(p, signal = 'SIGTERM', impl = {}) {
|
|
|
211
583
|
|
|
212
584
|
module.exports = {
|
|
213
585
|
defaultPidfilePath, readPidfile, writePidfile, removePidfile,
|
|
214
|
-
currentUid, readProcessStart, pidOwnership, pidExists, readCmdline,
|
|
586
|
+
currentUid, readProcessStart, probeProcessStart, pidOwnership, pidExists, readCmdline,
|
|
215
587
|
isAlive, isAttributable, cleanStale, killPidfile,
|
|
588
|
+
schemaMarkerPath, hasSchemaMarker, checkSchemaMarker, claimSchemaMarker,
|
|
216
589
|
};
|
|
@@ -208,8 +208,19 @@ function waitForPidExit(pid, timeoutMs = 2000) {
|
|
|
208
208
|
|
|
209
209
|
function stopPortableRuntime(opts = {}) {
|
|
210
210
|
const home = opts.home || require('node:os').homedir();
|
|
211
|
+
const log = opts.log || console.log;
|
|
211
212
|
const pidPath = pidf.defaultPidfilePath(home);
|
|
212
213
|
const result = pidf.killPidfile(pidPath);
|
|
214
|
+
// killPidfile puo' fermarsi su un'identita' non completamente verificata
|
|
215
|
+
// (unverifiedBirth: 'unsupported' | 'ambiguous-compat') e tornare comunque
|
|
216
|
+
// killed:true — il valore di ritorno da solo non basta: runner.js guarda
|
|
217
|
+
// solo `.killed` quando e' true e ignora il resto, quindi senza questa riga
|
|
218
|
+
// nessun operatore vedrebbe MAI che uno stop e' passato per il ramo
|
|
219
|
+
// permissivo. Qui, non nel chiamante: e' l'unico posto attraversato sia dal
|
|
220
|
+
// percorso manuale (commands.js stop/restart) sia dall'update automatico.
|
|
221
|
+
if (result.killed && result.unverifiedBirth) {
|
|
222
|
+
log(`stop portatile: pid ${result.pid} fermato con identita' NON completamente verificata (${result.unverifiedBirth}) — confermata solo per cmd, non per nascita`);
|
|
223
|
+
}
|
|
213
224
|
if (result.killed && !waitForPidExit(result.pid, opts.stopWaitMs || 2000)) {
|
|
214
225
|
return { ...result, killed: false, reason: `pid ${result.pid} did not exit after SIGTERM` };
|
|
215
226
|
}
|
package/lib/nodes/commands.js
CHANGED
|
@@ -204,8 +204,14 @@ function nodesAdd(opts) {
|
|
|
204
204
|
|
|
205
205
|
log(`nodes add: nodo "${name}" aggiunto (ssh ${ssh}${sshPort ? `:${sshPort}` : ''}, nexus remoto ${remotePort} -> locale ${localPort})`);
|
|
206
206
|
log('Incolla nel ~/.ssh/authorized_keys del NODO (lato forward, chiave dedicata):');
|
|
207
|
-
// permitopen
|
|
208
|
-
|
|
207
|
+
// R19: permitopen con le destinazioni EXPLICITE dei -L. Qui si conosce solo
|
|
208
|
+
// la porta nexus: la porta pannello del peer la annuncia il JOIN, non
|
|
209
|
+
// esiste ancora — quando apparirà, la riga completa (due destinazioni) la
|
|
210
|
+
// emette il pairing. Mai un permesso generico: è ciò che impedisce a questa
|
|
211
|
+
// chiave di aprire canali arbitrari. command=/bin/false + restrict.
|
|
212
|
+
const panelRemotePort = opts.panelRemotePort !== undefined ? Number(opts.panelRemotePort) : undefined;
|
|
213
|
+
const riga = tunnel.authorizedKeysLine({ remotePort, panelRemotePort, pub });
|
|
214
|
+
if (riga) log(riga);
|
|
209
215
|
return { code: 0, name, sshPort, localPort, remotePort, transport: entry.transport };
|
|
210
216
|
}
|
|
211
217
|
|
package/lib/nodes/health.js
CHANGED
|
@@ -102,6 +102,7 @@ async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = fal
|
|
|
102
102
|
transport: 'down', auth: 'unknown', reachability: 'unknown', status: 'down',
|
|
103
103
|
detail: diagnostic.detail, code: diagnostic.code, stage: diagnostic.stage,
|
|
104
104
|
...(diagnostic.hint ? { hint: diagnostic.hint } : {}),
|
|
105
|
+
...(diagnostic.authorizedKeys ? { authorizedKeys: diagnostic.authorizedKeys } : {}),
|
|
105
106
|
transportEngine: ts.transport || 'ssh', managed: ts.managed !== false, at: now,
|
|
106
107
|
};
|
|
107
108
|
} else if (!node.token) {
|
|
@@ -65,10 +65,16 @@ function recordPeerTransition(nodeName, health, diagnostics) {
|
|
|
65
65
|
`${nodeName}: tunnel ${previous.tunnel} -> ${tunnel}`,
|
|
66
66
|
{ node: nodeName, state: tunnel });
|
|
67
67
|
}
|
|
68
|
-
// Testabile solo quando il tunnel e
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
68
|
+
// Testabile solo quando il tunnel era SU e resta SU: solo allora entrambi
|
|
69
|
+
// gli estremi del confronto sono misure vere. Se e' appena caduto, il
|
|
70
|
+
// servizio passa a 'unknown' come artefatto del tunnel (gia' segnalato
|
|
71
|
+
// sopra), non come un fatto nuovo. Per SIMMETRIA vale anche al contrario:
|
|
72
|
+
// se e' appena tornato, il servizio non e' "tornato" — e' semplicemente di
|
|
73
|
+
// nuovo osservabile, e 'unknown' non era una misura precedente da cui
|
|
74
|
+
// partire. Scrivere "unknown -> ok" alla ripresa sarebbe un fatto inventato
|
|
75
|
+
// tanto quanto lo sarebbe "ok -> unknown" alla caduta: un record diagnostico
|
|
76
|
+
// che dichiara misurato cio' che non lo e' stato e' peggio del silenzio.
|
|
77
|
+
if (previous.tunnel === 'up' && tunnel === 'up' && previous.service !== service) {
|
|
72
78
|
diagnostics.record('warn', 'peer-health', 'SERVICE_TRANSITION',
|
|
73
79
|
`${nodeName}: service ${previous.service} -> ${service}`,
|
|
74
80
|
{ node: nodeName, state: service });
|