@mmmbuto/nexuscrew 0.9.5 → 0.9.7

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.
@@ -11,8 +11,8 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-BUbgwJsU.js"></script>
15
- <link rel="stylesheet" crossorigin href="/assets/index-BzmGsXBy.css">
14
+ <script type="module" crossorigin src="/assets/index-YpDRVy-I.js"></script>
15
+ <link rel="stylesheet" crossorigin href="/assets/index-DDAwkwKR.css">
16
16
  </head>
17
17
  <body>
18
18
  <div id="root"></div>
@@ -1 +1 @@
1
- {"version":"0.9.5"}
1
+ {"version":"0.9.7"}
package/lib/cli/init.js CHANGED
@@ -17,6 +17,7 @@ const {
17
17
  generateFleetService, installFleetService, migrationGate,
18
18
  selectProviderModeSync, fleetFileMode,
19
19
  } = require('./fleet-service.js');
20
+ const { resolveBootPaths } = require('./stable-alias.js');
20
21
  const { aggiornaDefinizioni } = require('../fleet/definitions.js');
21
22
  const { defaultDefinitions } = require('../fleet/managed.js');
22
23
  const { commandExists, resolveCommand } = require('./path.js');
@@ -234,6 +235,17 @@ function runInit(opts = {}) {
234
235
  port, home, uid: uid(),
235
236
  installPath: opts.installPath,
236
237
  };
238
+ // 1-bis (R23): la STESSA classe di difetto del companion di boot — nodeBin
239
+ // e entry finiscono versionati nell'unit/plist del servizio per-nodo. Si
240
+ // risolve qui, alla scrittura, e si dichiara cio' che non ha alias stabile:
241
+ // i due punti di scrittura non devono divergere (correggerne uno solo
242
+ // lascerebbe il difetto vivo).
243
+ const svcBoot = resolveBootPaths({
244
+ nodeBin: svcCtx.nodeBin,
245
+ entryPath: path.join(svcCtx.repoRoot, 'bin', 'nexuscrew.js'),
246
+ });
247
+ svcCtx.nodeBin = svcBoot.nodeBin;
248
+ svcCtx.entryPath = svcBoot.entryPath;
237
249
  const content = generateService(platform, svcCtx);
238
250
 
239
251
  if (!installBoot) {
@@ -250,6 +262,7 @@ function runInit(opts = {}) {
250
262
  : 'tmux non trovato su PATH (installa tmux)';
251
263
  actions.push(`WARN: ${reason} -> service NON installato (ri-runna init)`);
252
264
  } else {
265
+ for (const w of svcBoot.warnings) actions.push(`WARN service: ${w}`);
253
266
  try {
254
267
  const r = installService(platform, content, svcCtx, { execImpl: opts.execImpl });
255
268
  if (r.failures && r.failures.length) {
@@ -301,10 +314,17 @@ function runInit(opts = {}) {
301
314
  } else if (dryRun) {
302
315
  actions.push('DRY-RUN fleet companion generato, NON installato');
303
316
  } else {
317
+ // R23: i path del boot si risolvono alla SCRITTURA del servizio (mai
318
+ // a ogni avvio): un alias stabile che risolva allo stesso file, se
319
+ // c'e'; altrimenti il path attuale, DICHIARATO — un avviso adesso e'
320
+ // meglio di un path che scade in silenzio (vedi stable-alias.js).
321
+ const bootEntry = path.join(svcCtx.repoRoot, 'bin', 'nexuscrew.js');
322
+ const boot = resolveBootPaths({ nodeBin: svcCtx.nodeBin, entryPath: bootEntry });
323
+ for (const w of boot.warnings) actions.push(`WARN fleet companion: ${w}`);
304
324
  const fleetContent = generateFleetService({
305
325
  platform,
306
- nodeBin: svcCtx.nodeBin,
307
- entryPath: path.join(svcCtx.repoRoot, 'bin', 'nexuscrew.js'),
326
+ nodeBin: boot.nodeBin,
327
+ entryPath: boot.entryPath,
308
328
  repoRoot: svcCtx.repoRoot,
309
329
  home,
310
330
  });
@@ -53,6 +53,14 @@ function assertSystemdSafe(label, p) {
53
53
 
54
54
  // --- Templates ---
55
55
 
56
+ // Entry del servizio: di norma derivato da repoRoot; i punti di scrittura
57
+ // che risolvono l'alias stabile (R23/1-bis, stable-alias.js) passano
58
+ // l'entryPath gia' risolto. Contratto vecchio intatto: senza entryPath si
59
+ // deriva da repoRoot come prima.
60
+ function serviceEntryPath(ctx) {
61
+ return ctx.entryPath || path.join(ctx.repoRoot, 'bin', 'nexuscrew.js');
62
+ }
63
+
56
64
  function generateService(platform, ctx) {
57
65
  if (platform === 'linux') return generateLinux(ctx);
58
66
  if (platform === 'mac') return generateMac(ctx);
@@ -70,7 +78,7 @@ function generateLinux(ctx) {
70
78
  assertSystemdSafe('home', ctx.home);
71
79
  const runtime = escapeSystemdPath(ctx.home);
72
80
  const node = escapeSystemdExec(ctx.nodeBin);
73
- const repoBin = escapeSystemdExec(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
81
+ const repoBin = escapeSystemdExec(serviceEntryPath(ctx));
74
82
  const nodeDir = escapeSystemdPath(path.dirname(ctx.nodeBin));
75
83
  return `# NexusCrew service (systemd --user, loopback, solo tunnel SSH/VPN)
76
84
  [Unit]
@@ -96,7 +104,7 @@ WantedBy=default.target
96
104
 
97
105
  function generateMac(ctx) {
98
106
  const nodeXml = escapeXml(ctx.nodeBin);
99
- const repoBinXml = escapeXml(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
107
+ const repoBinXml = escapeXml(serviceEntryPath(ctx));
100
108
  // HOME remains stable when init or repair atomically replaces the runtime
101
109
  // directory; launchd must not retain an unlinked runtime-directory cwd.
102
110
  const runtimeXml = escapeXml(ctx.home);
@@ -146,7 +154,7 @@ function generateMac(ctx) {
146
154
 
147
155
  function generateTermux(ctx) {
148
156
  const nodeQ = shellQuote(ctx.nodeBin);
149
- const repoBinQ = shellQuote(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
157
+ const repoBinQ = shellQuote(serviceEntryPath(ctx));
150
158
  return `#!/data/data/com.termux/files/usr/bin/sh
151
159
  # NexusCrew boot (Termux) - loopback, localhost del telefono
152
160
  export PATH=/data/data/com.termux/files/usr/bin:$PATH
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+ // R23 — alias stabili per i path del servizio di boot.
3
+ //
4
+ // Il companion di boot scrive nodeBin e entryPath LETTERALMENTE nel plist
5
+ // launchd (ProgramArguments[0]) e nell'unit systemd (ExecStart=). Quei path
6
+ // possono essere VERSIONATI:
7
+ // node -> /opt/homebrew/Cellar/node/<ver>/bin/node (Homebrew)
8
+ // ~/.nvm/versions/node/<ver>/bin/node (nvm)
9
+ // entry -> ~/.nvm/versions/node/<ver>/lib/node_modules/<pkg>/bin/nexuscrew.js
10
+ // Homebrew rompe all'upgrade (sostituisce il Cellar), nvm rompe a
11
+ // `nvm uninstall` della vecchia versione: il servizio punta a un file che non
12
+ // esiste piu' e la cella non parte, con un sintomo confuso (il node c'e', il
13
+ // file no — o viceversa).
14
+ //
15
+ // La cura, per CIASCUNO dei due path: fra candidati noti, cercare un alias
16
+ // stabile che risolva (realpath) allo STESSO IDENTICO FILE che sta girando.
17
+ // Se c'e', scrivere quello; se no, scrivere il path attuale e DICHIARARLO:
18
+ // un avviso alla scrittura e' meglio di un path che scade in silenzio.
19
+ //
20
+ // Tre vincoli non negoziabili (briefing R23):
21
+ // 1. La verifica «e' lo stesso file» si fa alla SCRITTURA del servizio, MAI a
22
+ // ogni avvio: dopo l'upgrade il symlink DEVE puntare a un node diverso —
23
+ // e' l'effetto voluto. Un controllo di identita' permanente si
24
+ // autoannullerebbe.
25
+ // 2. Su nvm NON esiste un alias stabile (~/.nvm/current non c'e',
26
+ // ~/.nvm/alias/default e' un file di testo, `command -v node` risolve al
27
+ // path versionato): qui il risolutore non trova nulla e il ramo che conta
28
+ // e' quello che lo dichiara. Non inventare un alias nvm.
29
+ // 3. Il PATH del plist che include /opt/homebrew/bin NON e' una protezione:
30
+ // ProgramArguments[0] e' assoluto. Non contarci.
31
+
32
+ const fs = require('node:fs');
33
+ const path = require('node:path');
34
+
35
+ // Prefissi stabili noti (ordinati per probabilita'), senza $PREFIX che viene
36
+ // aggiunto solo se presente (Termux).
37
+ const STABLE_ROOTS = ['/opt/homebrew', '/usr/local', '/usr'];
38
+
39
+ function rootsWithPrefix(env = process.env) {
40
+ const roots = [...STABLE_ROOTS];
41
+ if (env && env.PREFIX) roots.push(env.PREFIX);
42
+ return roots;
43
+ }
44
+
45
+ // Candidati stabili per il BINARIO node. Almeno tre per costruzione —
46
+ // requisito del briefing: con uno solo «prova tutti i candidati» e «prova il
47
+ // primo» coincidono.
48
+ function nodeAliasCandidates(env = process.env) {
49
+ const c = rootsWithPrefix(env).map((r) => path.join(r, 'bin', 'node'));
50
+ return [...new Set(c)];
51
+ }
52
+
53
+ // Candidati stabili per l'ENTRY del pacchetto. Il suffisso dopo node_modules/
54
+ // (es. '@mmmbuto/nexuscrew/bin/nexuscrew.js') e' DERIVATO dal path che sta
55
+ // girando, non inventato: se il path non attraversa un node_modules (checkout
56
+ // di sviluppo) non ci sono candidati e il risolutore dichiarera' il path
57
+ // attuale — un checkout di sviluppo non scade, ma l'avviso e' il prezzo
58
+ // dell'onestà.
59
+ function entryAliasCandidates(entryPath, env = process.env) {
60
+ if (!entryPath || typeof entryPath !== 'string') return [];
61
+ const marker = `${path.sep}node_modules${path.sep}`;
62
+ const i = entryPath.lastIndexOf(marker);
63
+ if (i < 0) return [];
64
+ const rel = entryPath.slice(i + marker.length);
65
+ if (!rel) return [];
66
+ const c = rootsWithPrefix(env).map((r) => path.join(r, 'lib', 'node_modules', rel));
67
+ return [...new Set(c)];
68
+ }
69
+
70
+ // Risolutore: { path, alias, warning }.
71
+ // alias — il candidato scelto (realpath === realpath del path attuale),
72
+ // oppure null se nessuno risolve allo stesso file;
73
+ // path — l'alias se trovato, altrimenti il path attuale intatto;
74
+ // warning — null se trovato, altrimenti la dichiarazione scritta.
75
+ // realpathImpl e' iniettabile per i test deterministici; di default
76
+ // fs.realpathSync. Un candidato assente si salta (realpath lancia): l'assenza
77
+ // non e' un errore, e' solo un candidato in meno.
78
+ function resolveStableAlias(currentPath, candidates, opts = {}) {
79
+ const realpathImpl = typeof opts.realpath === 'function' ? opts.realpath : fs.realpathSync;
80
+ let currentReal = null;
81
+ try { currentReal = realpathImpl(currentPath); } catch (_) { currentReal = null; }
82
+ for (const cand of Array.isArray(candidates) ? candidates : []) {
83
+ let candReal;
84
+ try { candReal = realpathImpl(cand); } catch (_) { continue; } // assente: avanti
85
+ if (currentReal !== null && candReal === currentReal) {
86
+ return { path: cand, alias: cand, warning: null };
87
+ }
88
+ }
89
+ return {
90
+ path: currentPath,
91
+ alias: null,
92
+ warning: `nessun alias stabile per ${currentPath}: il servizio punta al path attuale; se l'installazione cambia (upgrade/uninstall di node) il path scade — rilancia l'installazione per rigenerare il servizio`,
93
+ };
94
+ }
95
+
96
+ // Composizione per il servizio di boot: risolve ENTRAMBI i path (node ed
97
+ // entry) e raccoglie le dichiarazioni. E' il punto che il flusso di
98
+ // installazione chiama alla SCRITTURA del servizio (vincolo 1: mai a ogni
99
+ // avvio). `env` e `realpath` iniettabili per test.
100
+ function resolveBootPaths({ nodeBin, entryPath, env = process.env, realpath } = {}) {
101
+ const opts = {};
102
+ if (typeof realpath === 'function') opts.realpath = realpath;
103
+ const node = resolveStableAlias(nodeBin, nodeAliasCandidates(env), opts);
104
+ const entry = resolveStableAlias(entryPath, entryAliasCandidates(entryPath, env), opts);
105
+ return {
106
+ nodeBin: node.path,
107
+ entryPath: entry.path,
108
+ warnings: [node.warning, entry.warning].filter((w) => w !== null),
109
+ };
110
+ }
111
+
112
+ module.exports = {
113
+ nodeAliasCandidates,
114
+ entryAliasCandidates,
115
+ resolveStableAlias,
116
+ resolveBootPaths,
117
+ };
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+ // Un SOLO formatter al confine UI/log per gli esiti del resolver della
3
+ // pubblica (job 5, strutturale). Prima le frasi si componevano in basso —
4
+ // nel supervisor e nella route — dove non si sa abbastanza per scriverle:
5
+ // meta' della 0.9.5 e' nata cosi' (promesse false, cause enumerate «a caso»,
6
+ // silenzi). Ora il resolver produce DATI enumerati (resolvePublicKey in
7
+ // tunnel.js) e QUESTO punto unico li trasforma in frasi. Un test per ogni
8
+ // causa.
9
+
10
+ const path = require('node:path');
11
+ const {
12
+ PUBKEY_DERIVED, PUBKEY_NO_IDENTITY, PUBKEY_ACTUAL_KEY_UNKNOWN,
13
+ PUBKEY_TOOL_UNAVAILABLE, PUBKEY_ENCRYPTED_OR_UNREADABLE,
14
+ } = require('./tunnel.js');
15
+
16
+ // La frase della CAUSA per ogni esito — il «perche'» che chi guarda deve
17
+ // leggere. `resolution` e' il dato del risolutore; `identityFile` il path
18
+ // dichiarato, per nominare l'oggetto giusto. Non inventare mai una causa che
19
+ // il dato non porta: l'ignosciuto si dice sconosciuto, non si indovina.
20
+ function pubkeyCauseText(resolution, { identityFile } = {}) {
21
+ const nome = identityFile ? path.basename(identityFile) : null;
22
+ switch (resolution && resolution.outcome) {
23
+ case PUBKEY_DERIVED:
24
+ // Nessuna causa da dichiarare: la riga c'e'.
25
+ return null;
26
+ case PUBKEY_NO_IDENTITY:
27
+ return `la chiave dichiarata${nome ? ` (${nome})` : ''} non esiste dove dovrebbe`;
28
+ case PUBKEY_ACTUAL_KEY_UNKNOWN:
29
+ return 'nessun -i: ssh usa le chiavi di default, un agent o la config, e da qui non si sa quale';
30
+ case PUBKEY_TOOL_UNAVAILABLE:
31
+ return 'ssh-keygen non e\' disponibile su questa macchina: la pubblica non si puo\' derivare';
32
+ case PUBKEY_ENCRYPTED_OR_UNREADABLE:
33
+ return `non riesco a derivare la pubblica da ${nome || 'quella chiave'}: e' cifrata o illeggibile`;
34
+ default:
35
+ // Al confine, l'ignosciuto si NOMINA: un silenzio qui sarebbe lo stesso
36
+ // difetto che questo modulo chiude.
37
+ return `esito non riconosciuto: ${resolution && resolution.outcome}`;
38
+ }
39
+ }
40
+
41
+ // L'AZIONE suggerita per ogni esito — il «cosa fare» accanto al perche'.
42
+ // Aspettare non serve dove bisogna concedere; riprovare non serve dove la
43
+ // condizione dipende da una decisione altrove.
44
+ function pubkeyActionText(resolution) {
45
+ switch (resolution && resolution.outcome) {
46
+ case PUBKEY_DERIVED:
47
+ return 'sostituisci la riga in ~/.ssh/authorized_keys del nodo con questa';
48
+ case PUBKEY_NO_IDENTITY:
49
+ return 'verifica il path della chiave dichiarata per questo nodo';
50
+ case PUBKEY_ACTUAL_KEY_UNKNOWN:
51
+ return 'individua la chiave che ssh usa davvero e modifica A MANO la sua riga';
52
+ case PUBKEY_TOOL_UNAVAILABLE:
53
+ return 'installa ssh-keygen (openssh-client), poi riprova';
54
+ case PUBKEY_ENCRYPTED_OR_UNREADABLE:
55
+ return 'usa una chiave dedicata non cifrata per questo nodo, o modifica A MANO la riga';
56
+ default:
57
+ return 'verifica la configurazione del nodo';
58
+ }
59
+ }
60
+
61
+ // La nota del pairing quando la riga c'e': il testo che accompagnava la
62
+ // risposta, ora composto qui (l'unico punto) e non nella route.
63
+ function pubkeyPairingNote(resolution, { panelPort } = {}) {
64
+ if (!resolution || resolution.outcome !== PUBKEY_DERIVED) return null;
65
+ return 'il peer ha un pannello sulla propria porta ' + panelPort
66
+ + ': SOSTITUISCI la riga già installata in ~/.ssh/authorized_keys del NODO con questa (due destinazioni), altrimenti il canale del pannello sarà rifiutato. È la riga della chiave DICHIARATA per questo nodo (-i), non un\'eventuale chiave "jump" dello stesso nodo. Se ssh sceglie un\'altra identità (agent o config), la riga giusta è quella della chiave che usa davvero.';
67
+ }
68
+
69
+ module.exports = { pubkeyCauseText, pubkeyActionText, pubkeyPairingNote };
@@ -178,28 +178,24 @@ function probeForwardChannels({ ports, graceMs = CHANNEL_GRACE_MS, connect = net
178
178
  // A MANO della riga esistente, mostrando solo le destinazioni da aggiungere.
179
179
  function refusalDetails({ remoteDestinations, identityFile } = {}) {
180
180
  const dests = (remoteDestinations || []).join('",permitopen="');
181
- const pub = require('./tunnel.js').readPublicKey(identityFile);
181
+ // Esito enumerato (resolver) + frase composta al confine UI/log — il punto
182
+ // unico pubkey-format. Prima qui si sceglieva fra cause che non si potevano
183
+ // distinguere e le si enumerava «a caso»; ora il dato distingue, e il
184
+ // testo dice la causa ESATTA: mai inventarne una che il dato non porta.
185
+ const resolution = require('./tunnel.js').resolvePublicKey(identityFile);
186
+ const { pubkeyCauseText, pubkeyActionText } = require('./pubkey-format.js');
182
187
  const premessa = 'canale rifiutato dal NODO remoto: la chiave in ~/.ssh/authorized_keys non autorizza queste destinazioni.';
183
- if (!pub) {
184
- // Due situazioni diverse, e il messaggio non deve confonderle — ne'
185
- // inventare una causa che non conosce. Senza `-i` la chiave non e'
186
- // dichiarata affatto. Con `-i`, la derivazione dalla privata puo' fallire
187
- // per piu' motivi (privata assente o illeggibile, protetta da passphrase,
188
- // `ssh-keygen` non disponibile) e da qui non si distinguono: si elencano,
189
- // invece di sceglierne uno a caso. Dire «la parte pubblica non e'
190
- // leggibile» a chi ha una chiave cifrata col suo `.pub` accanto e' falso,
191
- // e manda a cercare il problema dove non e'.
192
- const causa = identityFile
193
- ? `non riesco a derivare la chiave pubblica da ${path.basename(identityFile)}`
194
- + ' (privata assente o illeggibile, protetta da passphrase, oppure ssh-keygen non disponibile)'
195
- : 'nessun -i: ssh usa le chiavi di default, un agent o la config, e da qui non si sa quale';
188
+ if (resolution.outcome !== require('./tunnel.js').PUBKEY_DERIVED) {
189
+ const causa = pubkeyCauseText(resolution, { identityFile });
190
+ const azione = pubkeyActionText(resolution);
196
191
  return {
197
- hint: `${premessa} NON posso identificare la chiave da correggere — ${causa}`
198
- + `. Modifica A MANO la riga di quella chiave aggiungendo queste destinazioni: permitopen="${dests}"`,
192
+ hint: `${premessa} NON posso identificare la chiave da correggere — ${causa}. ${azione}`
193
+ + `: aggiungi queste destinazioni: permitopen="${dests}"`,
199
194
  authorizedKeys: '',
195
+ outcome: resolution.outcome,
200
196
  };
201
197
  }
202
- const riga = `restrict,port-forwarding,permitopen="${dests}",command="/bin/false" ${pub}`;
198
+ const riga = `restrict,port-forwarding,permitopen="${dests}",command="/bin/false" ${resolution.line}`;
203
199
  // NON diciamo «quella con cui il supervisor si autentica»: `-i` DICHIARA
204
200
  // un'identita', non la impone — senza `IdentitiesOnly=yes` OpenSSH puo'
205
201
  // comunque usare l'agent o la config, e il canale essere autenticato da
@@ -209,6 +205,7 @@ function refusalDetails({ remoteDestinations, identityFile } = {}) {
209
205
  return {
210
206
  hint: `${premessa} Riga da usare (SOSTITUISCI quella esistente, non aggiungerne una seconda)${quale}: ${riga}`,
211
207
  authorizedKeys: riga,
208
+ outcome: resolution.outcome,
212
209
  };
213
210
  }
214
211
 
@@ -732,8 +732,37 @@ function authorizedKeysLine({ remotePort, panelRemotePort, pub } = {}) {
732
732
  // Torna null se la privata non e' leggibile, e' protetta da passphrase (in
733
733
  // batch non si puo' sbloccare) o `ssh-keygen` non c'e': senza poterla derivare
734
734
  // non si compone nessuna riga.
735
- function readPublicKey(identityFile, impl = {}) {
736
- if (!identityFile || typeof identityFile !== 'string') return null;
735
+ // Esiti ENUMERATI della derivazione della pubblica (difetto strutturale
736
+ // registrato dall'auditor: `null | stringa` comprimeva stati DIVERSI, e il
737
+ // livello basso componeva frasi su fatti che non conosce — meta' della 0.9.5
738
+ // e' nata li'). Gli esiti sono DATI, senza testo per l'utente: le frasi si
739
+ // compongono al confine UI/log, mai qui. Si aggancia a P4: una chiave cifrata
740
+ // dava `null`, indistinguibile da «file illeggibile», e il pairing ometteva
741
+ // la riga in silenzio.
742
+ //
743
+ // derived — pubblica derivata; `line` e' la riga
744
+ // no-identity — identityFile dichiarata ma il file NON esiste
745
+ // actual-key-unknown — identityFile non dichiarata: la chiave che ssh
746
+ // usera' DAVVERO (default, agent, config) non e'
747
+ // sapibile da qui; nessun altro canale ripara
748
+ // tool-unavailable — ssh-keygen assente sulla macchina
749
+ // encrypted-or-unreadable — ssh-keygen non riesce sul file (cifrata,
750
+ // permessi, formato) o l'output non e' una riga
751
+ // pubblica valida
752
+ const PUBKEY_DERIVED = 'derived';
753
+ const PUBKEY_NO_IDENTITY = 'no-identity';
754
+ const PUBKEY_ACTUAL_KEY_UNKNOWN = 'actual-key-unknown';
755
+ const PUBKEY_TOOL_UNAVAILABLE = 'tool-unavailable';
756
+ const PUBKEY_ENCRYPTED_OR_UNREADABLE = 'encrypted-or-unreadable';
757
+
758
+ function resolvePublicKey(identityFile, impl = {}) {
759
+ if (!identityFile || typeof identityFile !== 'string' || identityFile.trim() === '') {
760
+ return { outcome: PUBKEY_ACTUAL_KEY_UNKNOWN };
761
+ }
762
+ const existsImpl = impl.existsSync || require('node:fs').existsSync;
763
+ if (!existsImpl(identityFile)) {
764
+ return { outcome: PUBKEY_NO_IDENTITY, path: identityFile };
765
+ }
737
766
  const exec = impl.execImpl || require('node:child_process').execFileSync;
738
767
  // Timeout iniettabile (test): il default 5000 ms e' la RETE, non il
739
768
  // meccanismo — il meccanismo e' l'ambiente controllato qui sotto.
@@ -757,7 +786,7 @@ function readPublicKey(identityFile, impl = {}) {
757
786
  // chiave cifrata (misurato: 0.11 s contro gli 8 s dell'attesa su tty) e
758
787
  // non puo' lasciare helper. Un path inesistente vince su /bin/false: su
759
788
  // Termux i binari stanno sotto $PREFIX, e un path assoluto sbagliato e'
760
- // una scommessa.
789
+ // una scommessa; e non `setsid`, che su macOS non esiste.
761
790
  // LIMITE DA SAPERE: SSH_ASKPASS_REQUIRE esiste da OpenSSH 8.4. Le versioni
762
791
  // piu' vecchie lo ignorano e tornano al tty: li' il timeout qui sopra
763
792
  // resta come rete — il degrado e' «lento», non «appeso».
@@ -770,14 +799,32 @@ function readPublicKey(identityFile, impl = {}) {
770
799
  SSH_ASKPASS: path.join(process.env.HOME || os.homedir(), '.nexuscrew', 'askpass-inesistente'),
771
800
  },
772
801
  });
773
- } catch (_) { return null; } // assente, passphrase, privata illeggibile
802
+ } catch (e) {
803
+ // ENOENT dello SPAWN = il binario ssh-keygen non esiste: un'altra causa
804
+ // rispetto al file che non si deriva, e chi compone il messaggio deve
805
+ // poterle distinguere (prima erano lo stesso null).
806
+ if (e && e.code === 'ENOENT') return { outcome: PUBKEY_TOOL_UNAVAILABLE };
807
+ return { outcome: PUBKEY_ENCRYPTED_OR_UNREADABLE };
808
+ }
774
809
  const righe = String(out || '').split('\n').filter((r) => r.trim() !== '');
775
- if (righe.length !== 1) return null;
810
+ if (righe.length !== 1) return { outcome: PUBKEY_ENCRYPTED_OR_UNREADABLE };
776
811
  const riga = righe[0].trim();
777
- if (/[\u0000-\u0008\u000b-\u001f\u007f]/.test(riga)) return null;
812
+ if (riga.length === 0 || /[\u0000-\u0008\u000b-\u001f\u007f]/.test(riga)) {
813
+ return { outcome: PUBKEY_ENCRYPTED_OR_UNREADABLE };
814
+ }
778
815
  const campi = riga.split(/[ \t]+/);
779
- if (campi.length < 2 || !/^[A-Za-z0-9+/]+={0,2}$/.test(campi[1])) return null;
780
- return riga;
816
+ if (campi.length < 2 || !/^[A-Za-z0-9+/]+={0,2}$/.test(campi[1])) {
817
+ return { outcome: PUBKEY_ENCRYPTED_OR_UNREADABLE };
818
+ }
819
+ return { outcome: PUBKEY_DERIVED, line: riga };
820
+ }
821
+
822
+ // Contratto storico (supervisor e chiamanti esistenti): null | stringa.
823
+ // Sopra resolvePublicKey per gli esiti enumerati; questo resta per chi
824
+ // compone ancora sul contratto vecchio — il confine UI/log migrera'.
825
+ function readPublicKey(identityFile, impl = {}) {
826
+ const r = resolvePublicKey(identityFile, impl);
827
+ return r.outcome === PUBKEY_DERIVED ? r.line : null;
781
828
  }
782
829
 
783
830
  // La riga per UN nodo, dal nodo. Sta qui e non nella route perche' la stessa
@@ -796,14 +843,21 @@ function readPublicKey(identityFile, impl = {}) {
796
843
  // provisionare un'identita' per peer — lavoro suo, non un ramo di questa
797
844
  // funzione.
798
845
  function authorizedKeysForNode(nodo, panelRemotePort) {
799
- const pub = readPublicKey(nodo && nodo.identityFile);
800
- if (!pub) return null;
801
- return authorizedKeysLine({ remotePort: nodo.remotePort, panelRemotePort, pub });
846
+ // Esito enumerato, non piu' null|stringa: chi risponde (pairing) porta il
847
+ // DATO fino al confine, e li' — non qui — si compone cio' che si dice.
848
+ const r = resolvePublicKey(nodo && nodo.identityFile);
849
+ if (r.outcome !== PUBKEY_DERIVED) return { outcome: r.outcome, line: null };
850
+ return {
851
+ outcome: r.outcome,
852
+ line: authorizedKeysLine({ remotePort: nodo.remotePort, panelRemotePort, pub: r.line }),
853
+ };
802
854
  }
803
855
 
804
856
  module.exports = {
805
857
  SSH_BASE_OPTS,
806
- authorizedKeysLine, readPublicKey, authorizedKeysForNode,
858
+ authorizedKeysLine, readPublicKey, resolvePublicKey, authorizedKeysForNode,
859
+ PUBKEY_DERIVED, PUBKEY_NO_IDENTITY, PUBKEY_ACTUAL_KEY_UNKNOWN,
860
+ PUBKEY_TOOL_UNAVAILABLE, PUBKEY_ENCRYPTED_OR_UNREADABLE,
807
861
  buildForwardArgs, buildReverseArgs, backoffDelay,
808
862
  tunnelDir, tunnelPidPath, tunnelLogPath, tunnelStatePath, readTunnelState,
809
863
  prepareTunnelDir, openTunnelLog,
@@ -423,11 +423,17 @@ function createPairHandler(deps) {
423
423
  }
424
424
  send(res, 200, {
425
425
  paired: true, name: b.name, instanceId: joined.instanceId, transport: 'auto', health: { status: health.status },
426
- ...(authorizedKeysPayload ? {
427
- authorizedKeys: authorizedKeysPayload,
428
- authorizedKeysNote: 'il peer ha un pannello sulla propria porta ' + peerPanelPort
429
- + ': SOSTITUISCI la riga già installata in ~/.ssh/authorized_keys del NODO con questa (due destinazioni), altrimenti il canale del pannello sarà rifiutato. È la riga della chiave DICHIARATA per questo nodo (-i), non un\'eventuale chiave "jump" dello stesso nodo. Se ssh sceglie un\'altra identità (agent o config), la riga giusta è quella della chiave che usa davvero.',
426
+ ...(authorizedKeysPayload && authorizedKeysPayload.outcome === 'derived' ? {
427
+ authorizedKeys: authorizedKeysPayload.line,
428
+ // La nota si compone al confine UI/log (pubkey-format), non qui.
429
+ authorizedKeysNote: require('../nodes/pubkey-format.js')
430
+ .pubkeyPairingNote(authorizedKeysPayload, { panelPort: peerPanelPort }),
430
431
  } : {}),
432
+ // Esito enumerato come DATO (resolver a cinque esiti): prima una
433
+ // chiave cifrata era indistinguibile da «file illeggibile» e la riga
434
+ // spariva in silenzio. Il confine UI/log compone le frasi su questo;
435
+ // qui si porta il fatto, non il testo.
436
+ ...(authorizedKeysPayload ? { authorizedKeysOutcome: authorizedKeysPayload.outcome } : {}),
431
437
  });
432
438
  } catch (e) {
433
439
  await rollback();
@@ -57,6 +57,7 @@ const {
57
57
  } = require('../proxy/federation.js');
58
58
  const { rotateToken } = require('../auth/token.js');
59
59
  const { generateService, installService, installPath: svcInstallPath } = require('../cli/service.js');
60
+ const { resolveBootPaths } = require('../cli/stable-alias.js');
60
61
  const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js');
61
62
  const { isServiceRunning, readRoles, bootState } = require('../cli/commands.js');
62
63
  const { configJsonPath } = require('../config.js');
@@ -993,6 +994,15 @@ function settingsRoutes(deps = {}) {
993
994
  uid: seams.uid || uid(),
994
995
  installPath: seams.serviceInstallPath,
995
996
  };
997
+ // 1-bis (R23): come in init — il path del boot si risolve alla
998
+ // scrittura e cio' che non ha alias stabile torna DICHIARATO. Le
999
+ // warnings sono DATI nella risposta, non testo composto qui.
1000
+ const svcBoot = resolveBootPaths({
1001
+ nodeBin: ctx.nodeBin,
1002
+ entryPath: path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'),
1003
+ });
1004
+ ctx.nodeBin = svcBoot.nodeBin;
1005
+ ctx.entryPath = svcBoot.entryPath;
996
1006
  const content = generateService(platform, ctx);
997
1007
  const out = installService(platform, content, ctx, { activate: false });
998
1008
  send(res, 200, {
@@ -1000,6 +1010,7 @@ function settingsRoutes(deps = {}) {
1000
1010
  target: out.target,
1001
1011
  note: 'unit rigenerata; nessun restart automatico — riavvia il service per applicarla',
1002
1012
  skippedActivation: out.skippedActivation,
1013
+ warnings: svcBoot.warnings,
1003
1014
  });
1004
1015
  } catch (e) { send(res, 500, { error: String(e.message || e) }); }
1005
1016
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {