@mmmbuto/nexuscrew 0.7.7 → 0.8.2

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.
Files changed (51) hide show
  1. package/README.md +153 -25
  2. package/bin/nexuscrew.js +10 -4
  3. package/frontend/dist/assets/index-COsoJxXQ.css +32 -0
  4. package/frontend/dist/assets/index-Dey9rdY-.js +90 -0
  5. package/frontend/dist/assets/qr-scanner-worker.min-D85Z9gVD.js +98 -0
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/dist/sw.js +29 -0
  8. package/frontend/dist/version.json +1 -0
  9. package/lib/auth/middleware.js +7 -1
  10. package/lib/auth/token.js +31 -1
  11. package/lib/cli/commands.js +502 -43
  12. package/lib/cli/doctor.js +160 -0
  13. package/lib/cli/fleet-service.js +16 -3
  14. package/lib/cli/init.js +69 -11
  15. package/lib/cli/path.js +24 -0
  16. package/lib/cli/service.js +14 -8
  17. package/lib/cli/url.js +63 -0
  18. package/lib/config.js +5 -0
  19. package/lib/decks/routes.js +81 -0
  20. package/lib/decks/store.js +123 -0
  21. package/lib/files/routes.js +57 -1
  22. package/lib/files/store.js +16 -1
  23. package/lib/fleet/builtin.js +151 -24
  24. package/lib/fleet/definitions.js +26 -0
  25. package/lib/fleet/managed.js +381 -0
  26. package/lib/fleet/routes.js +5 -4
  27. package/lib/mcp/server.js +381 -0
  28. package/lib/nodes/commands.js +411 -0
  29. package/lib/nodes/peering.js +116 -0
  30. package/lib/nodes/store.js +425 -0
  31. package/lib/nodes/tunnel-supervisor.js +102 -0
  32. package/lib/nodes/tunnel.js +315 -0
  33. package/lib/notify/asks.js +150 -0
  34. package/lib/notify/events.js +59 -0
  35. package/lib/notify/notifier.js +37 -0
  36. package/lib/notify/persist.js +73 -0
  37. package/lib/notify/push.js +289 -0
  38. package/lib/notify/routes.js +260 -0
  39. package/lib/proxy/federation.js +217 -0
  40. package/lib/proxy/node-proxy.js +305 -0
  41. package/lib/pty/attach.js +34 -9
  42. package/lib/pty/provider.js +21 -6
  43. package/lib/server.js +291 -14
  44. package/lib/settings/routes.js +685 -0
  45. package/lib/ws/bridge.js +10 -1
  46. package/package.json +8 -2
  47. package/skills/nexuscrew-agent/SKILL.md +83 -0
  48. package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
  49. package/skills/nexuscrew-agent/bin/nc-send +48 -0
  50. package/frontend/dist/assets/index-DUbtTZMj.css +0 -32
  51. package/frontend/dist/assets/index-EVa9bnAC.js +0 -81
@@ -0,0 +1,685 @@
1
+ 'use strict';
2
+ // lib/settings/routes.js — Settings API per wizard/settings UI (design §4b(6), B2).
3
+ //
4
+ // Contratto duro:
5
+ // - read-only: GET /api/settings (roles, firstRun, port, platform, service,
6
+ // rendezvous redatto, version).
7
+ // - mutanti (LISTA CHIUSA §4b(6)), tutti dietro requireToken (montati sotto il
8
+ // router /api gia' autenticato) + gate READONLY route-level (pattern
9
+ // lib/fleet/routes.js, 403 esplicito):
10
+ // POST /config scrittura config.json ATOMICA (whitelist chiavi)
11
+ // POST /token/rotate riusa rotateToken(); token MAI in risposta (§4b(3))
12
+ // POST /nodes nodes add (riusa lib/nodes/commands.js)
13
+ // DELETE /nodes/:name nodes remove
14
+ // POST /node-role node on/off (rendezvous config)
15
+ // POST /service/regenerate rigenera unit service (NO restart automatico)
16
+ // - NON gate READONLY (lifecycle di PROCESSO, non mutazione di config — decisione
17
+ // B0 documentata in lib/nodes/commands.js: READONLY blocca le mutazioni di
18
+ // config, non up/down/restart dei tunnel):
19
+ // POST /nodes/:name/test non-mutante (POST per coerenza azione)
20
+ // POST /nodes/:name/up|down|restart
21
+ //
22
+ // Invarianti:
23
+ // - token MAI in risposta: ogni payload passa da deepScrubTokens() (cintura oltre
24
+ // alla redazione a monte via redactStore); rotateToken() ritorna il nuovo token
25
+ // ma il valore viene SCARTATO, mai serializzato.
26
+ // - failure esplicite: {error:'causa precisa'} con status coerente (400 input,
27
+ // 403 readonly, 404 nodo, 409 conflitto, 500 con messaggio). Niente stack trace.
28
+ // - validazione input strict fail-closed: schema chiuso per ogni body, garbage
29
+ // -> 400 con causa, mai guess.
30
+ // - NIENTE reimplementazione della logica B0: i mutanti nodes/node-role
31
+ // incapsulano le funzioni CLI di lib/nodes/commands.js (log catturato per
32
+ // estrarre esito/authorized_keys); config.json scritto col pattern atomico
33
+ // tmp+rename di lib/nodes/store.js.
34
+ //
35
+ // Nota lifecycle: i tunnel avviati da /nodes/:name/up sono spawn DETACHED+unref
36
+ // con pidfile (lib/nodes/tunnel.js): non restano figli dell'handler HTTP; il
37
+ // commento B0 "mai spawn dalla superficie web" e' superato per B2 da questo
38
+ // contratto esplicito (§4b(6): "restart processi tunnel" e' mutazione ammessa).
39
+ const fs = require('node:fs');
40
+ const os = require('node:os');
41
+ const path = require('node:path');
42
+ const crypto = require('node:crypto');
43
+ const express = require('express');
44
+
45
+ const nodesStore = require('../nodes/store.js');
46
+ const nodesCmds = require('../nodes/commands.js');
47
+ const nodesTunnel = require('../nodes/tunnel.js');
48
+ const peering = require('../nodes/peering.js');
49
+ const { rotateToken } = require('../auth/token.js');
50
+ const { generateService, installService, installPath: svcInstallPath } = require('../cli/service.js');
51
+ const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js');
52
+ const { isServiceRunning, readRoles } = require('../cli/commands.js');
53
+ const { configJsonPath } = require('../config.js');
54
+ const VERSION = require('../../package.json').version;
55
+
56
+ // Whitelist chiavi scrivibili di config.json via API (lista chiusa dal task B2).
57
+ const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone']);
58
+ const ROLE_KEYS = new Set(['client', 'node']);
59
+ const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath']);
60
+ const NODE_ROLE_KEYS = new Set(['enabled', 'rendezvousSsh', 'publishedPort', 'keyPath']);
61
+
62
+ // Cintura §4b(3): rimozione ricorsiva di ogni chiave `token` da QUALUNQUE payload
63
+ // di risposta (la vista redatta espone hasToken, mai il valore). Difesa in
64
+ // profondita' oltre a redactStore: anche un bug a monte non fa uscire il segreto.
65
+ function deepScrubTokens(v) {
66
+ if (Array.isArray(v)) return v.map(deepScrubTokens);
67
+ if (v && typeof v === 'object') {
68
+ const out = {};
69
+ for (const [k, val] of Object.entries(v)) {
70
+ if (k === 'token') continue;
71
+ out[k] = deepScrubTokens(val);
72
+ }
73
+ return out;
74
+ }
75
+ return v;
76
+ }
77
+
78
+ // Cattura il log delle funzioni CLI riusate: serve per estrarre la causa d'errore
79
+ // precisa e la riga authorized_keys (che NON e' un segreto: pubkey con restrict).
80
+ function capture() {
81
+ const lines = [];
82
+ return { lines, log: (s) => lines.push(String(s)) };
83
+ }
84
+
85
+ function lastLine(cap, fallback) {
86
+ return cap.lines.length ? cap.lines[cap.lines.length - 1] : fallback;
87
+ }
88
+
89
+ function authorizedKeysLine(cap) {
90
+ return cap.lines.find((l) => l.startsWith('restrict,')) || null;
91
+ }
92
+
93
+ // Scrittura ATOMICA di config.json: tmp stessa dir (suffisso random: due write
94
+ // concorrenti nello stesso processo non collidono) -> chmod 0600 -> rename.
95
+ // Stesso pattern hardening di lib/nodes/store.js (no-symlink incluso).
96
+ function atomicWriteConfig(p, obj) {
97
+ try {
98
+ if (fs.lstatSync(p).isSymbolicLink()) {
99
+ throw new Error('refuse to write: config.json target e\' un symlink');
100
+ }
101
+ } catch (e) {
102
+ if (e.code !== 'ENOENT') throw e;
103
+ }
104
+ const dir = path.dirname(p);
105
+ fs.mkdirSync(dir, { recursive: true });
106
+ const tmp = path.join(dir, `.${path.basename(p)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
107
+ try {
108
+ fs.writeFileSync(tmp, `${JSON.stringify(obj, null, 2)}\n`, { mode: 0o600 });
109
+ fs.chmodSync(tmp, 0o600);
110
+ fs.renameSync(tmp, p);
111
+ } catch (e) {
112
+ try { fs.unlinkSync(tmp); } catch (_) { /* cleanup best-effort */ }
113
+ throw e;
114
+ }
115
+ }
116
+
117
+ // config.json corrente come oggetto ({} se assente/illeggibile) + flag esistenza.
118
+ function readConfigFile(p) {
119
+ try {
120
+ const obj = JSON.parse(fs.readFileSync(p, 'utf8'));
121
+ return { exists: true, cfg: (obj && typeof obj === 'object' && !Array.isArray(obj)) ? obj : {} };
122
+ } catch (_) { return { exists: false, cfg: {} }; }
123
+ }
124
+
125
+ function settingsRoutes(deps = {}) {
126
+ const cfg = deps.cfg || {};
127
+ const seams = cfg.settingsSeams || {};
128
+ // tokenStore/closeSessions iniettati da server.js per la semantica di invalidazione
129
+ // live (audit F7 / §4b(3)). Assenti nei test unitari puri su routes -> la rotazione
130
+ // scrive solo il file (come prima), senza reload in-memory.
131
+ const tokenStore = deps.tokenStore || null;
132
+ const closeSessions = deps.closeSessions || null;
133
+ const home = cfg.home || os.homedir();
134
+ const configDir = cfg.configDir || path.join(home, '.nexuscrew');
135
+ // configJsonPath() rispetta NEXUSCREW_CONFIG_FILE (stessa risoluzione di
136
+ // loadConfig): settings API e server DEVONO leggere/scrivere lo STESSO file —
137
+ // il fallback home-based divergeva dalla config del server nelle istanze
138
+ // isolate via env (bug trovato in audit: smoke test che scrivono la config reale).
139
+ const configPath = cfg.configPath || configJsonPath();
140
+ const nodesPath = deps.nodesPath || cfg.nodesPath || nodesStore.defaultNodesPath(home);
141
+ const tokenPath = cfg.tokenPath || path.join(configDir, 'token');
142
+ const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
143
+ const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
144
+ const platform = seams.platform || detectPlatform();
145
+
146
+ const r = express.Router();
147
+ r.use(express.json({ limit: '8kb' }));
148
+
149
+ const send = (res, status, payload) => res.status(status).json(deepScrubTokens(payload));
150
+
151
+ // Gate READONLY route-level (pattern lib/fleet/routes.js): PRIMA di qualunque
152
+ // dispatch verso le funzioni CLI. Vale per i soli mutanti di config/token/service.
153
+ const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
154
+ const mutGate = (_req, res, next) => {
155
+ if (readonly()) return send(res, 403, { error: 'READONLY: mutazione settings bloccata' });
156
+ next();
157
+ };
158
+
159
+ // Opzioni comuni passate alle funzioni CLI riusate (path espliciti + seam test).
160
+ const cliOpts = (cap) => ({
161
+ home, configDir, configPath, nodesPath, log: cap.log,
162
+ keygen: seams.keygen, execFileImpl: seams.execFileImpl,
163
+ spawnImpl: seams.spawnImpl, sshBin: seams.sshBin, logFd: seams.logFd,
164
+ httpProbe: seams.httpProbe, sshVersion: seams.sshVersion,
165
+ spawnSyncImpl: seams.spawnSyncImpl,
166
+ });
167
+
168
+ const validName = (name) => typeof name === 'string' && nodesStore.NODE_NAME_RE.test(name);
169
+
170
+ // --- GET / — vista read-only per wizard/settings UI -----------------------
171
+ r.get('/', (_req, res) => {
172
+ try {
173
+ const { exists, cfg: fileCfg } = readConfigFile(configPath);
174
+ // firstRun: config.json assente O wizardDone non esplicitamente true
175
+ // (il wizard persiste wizardDone:true via POST /config a fine setup).
176
+ const firstRun = !exists || fileCfg.wizardDone !== true;
177
+ const svcPath = seams.serviceInstallPath || svcInstallPath(platform, home);
178
+ const service = {
179
+ installed: fs.existsSync(svcPath),
180
+ active: isServiceRunning({ platform, execImpl: seams.execImpl, uid: seams.uid, home }),
181
+ };
182
+ const out = {
183
+ roles: readRoles(configPath),
184
+ firstRun,
185
+ port: cfg.port,
186
+ platform,
187
+ service,
188
+ version: VERSION,
189
+ };
190
+ // rendezvous via redactStore (view sicura §4b(4)): non contiene token, ma
191
+ // la si prende comunque SOLO dalla vista redatta.
192
+ const st = nodesStore.loadStore(nodesPath);
193
+ if (st) {
194
+ const view = nodesStore.redactStore(st);
195
+ if (view.rendezvous) out.rendezvous = view.rendezvous;
196
+ }
197
+ send(res, 200, out);
198
+ } catch (e) { send(res, 500, { error: String(e.message || e) }); }
199
+ });
200
+
201
+ // --- POST /config — scrittura atomica, subset whitelisted ------------------
202
+ r.post('/config', mutGate, (req, res) => {
203
+ try {
204
+ const b = req.body;
205
+ if (!b || typeof b !== 'object' || Array.isArray(b)) {
206
+ return send(res, 400, { error: 'body deve essere un oggetto JSON' });
207
+ }
208
+ for (const k of Object.keys(b)) {
209
+ if (!CONFIG_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (whitelist: roles, port, wizardDone)` });
210
+ }
211
+ if (Object.keys(b).length === 0) {
212
+ return send(res, 400, { error: 'nessuna chiave da scrivere (whitelist: roles, port, wizardDone)' });
213
+ }
214
+ if (b.roles !== undefined) {
215
+ if (!b.roles || typeof b.roles !== 'object' || Array.isArray(b.roles)) {
216
+ return send(res, 400, { error: 'roles deve essere un oggetto {client?, node?}' });
217
+ }
218
+ for (const k of Object.keys(b.roles)) {
219
+ if (!ROLE_KEYS.has(k)) return send(res, 400, { error: `roles: chiave non ammessa "${k}" (solo client, node)` });
220
+ if (typeof b.roles[k] !== 'boolean') return send(res, 400, { error: `roles.${k} deve essere boolean` });
221
+ }
222
+ }
223
+ if (b.port !== undefined && !nodesStore.isPort(b.port)) {
224
+ return send(res, 400, { error: 'port deve essere un intero 1..65535' });
225
+ }
226
+ if (b.wizardDone !== undefined && typeof b.wizardDone !== 'boolean') {
227
+ return send(res, 400, { error: 'wizardDone deve essere boolean' });
228
+ }
229
+
230
+ // merge sul config esistente (preserva le chiavi non gestite qui)
231
+ const { cfg: current } = readConfigFile(configPath);
232
+ const next = { ...current };
233
+ if (b.roles !== undefined) {
234
+ const prev = (current.roles && typeof current.roles === 'object') ? current.roles : {};
235
+ next.roles = { client: !!prev.client, node: !!prev.node, ...b.roles };
236
+ }
237
+ if (b.port !== undefined) next.port = b.port;
238
+ if (b.wizardDone !== undefined) next.wizardDone = b.wizardDone;
239
+ atomicWriteConfig(configPath, next);
240
+
241
+ const out = {
242
+ saved: true,
243
+ config: { roles: next.roles, port: next.port, wizardDone: next.wizardDone },
244
+ };
245
+ // Il server legge la porta SOLO allo startup: il cambio vale al prossimo
246
+ // restart (contratto dichiarato nella risposta, la UI avvisa).
247
+ if (b.port !== undefined && b.port !== cfg.port) {
248
+ out.note = 'la porta cambia al prossimo restart del service';
249
+ }
250
+ send(res, 200, out);
251
+ } catch (e) { send(res, 500, { error: String(e.message || e) }); }
252
+ });
253
+
254
+ // --- POST /token/rotate — atomico + reload live + chiusura WS; token MAI in risposta
255
+ // Contratto §4b(3): "scrittura atomica del token file + chiusura delle sessioni
256
+ // WS/API attive locali + reload credenziali proxy". Audit F7: prima questa route
257
+ // scriveva solo il file, e il server teneva il VECCHIO token in memoria -> restava
258
+ // accettato fino al restart manuale. Ora: (1) scrittura atomica, (2) reload live
259
+ // dell'holder in memoria (requireToken/verify vedono il nuovo), (3) chiusura delle
260
+ // sessioni WS long-lived (autenticate solo all'upgrade). Il nuovo token NON trapela
261
+ // mai: rotateToken() lo ritorna ma il valore viene SCARTATO, e reload() non lo espone.
262
+ r.post('/token/rotate', mutGate, (_req, res) => {
263
+ try {
264
+ rotateToken(tokenPath);
265
+ if (tokenStore && typeof tokenStore.reload === 'function') tokenStore.reload();
266
+ if (typeof closeSessions === 'function') closeSessions();
267
+ send(res, 200, {
268
+ rotated: true,
269
+ note: 'token ruotato: sessioni attive chiuse, vecchio token invalidato (401) — recupera il nuovo con `nexuscrew url`',
270
+ });
271
+ } catch (e) { send(res, 500, { error: String(e.message || e) }); }
272
+ });
273
+
274
+ // One-time PWA pairing capability. It is not the UI bearer token and is
275
+ // persisted hashed, 0600, for ten minutes only.
276
+ r.post('/peering/invite', mutGate, (_req, res) => {
277
+ try {
278
+ const st = nodesStore.loadOrInitStore(nodesPath);
279
+ send(res, 200, peering.createInvite({
280
+ invitesPath, instanceId: st.nodeId, port: cfg.port, label: os.hostname(),
281
+ }));
282
+ } catch (e) { send(res, 500, { error: String(e.message || e) }); }
283
+ });
284
+
285
+ // Hydra join: add once from the PWA. A provisional -L reaches the invite
286
+ // endpoint, then the negotiated -R makes the same SSH link reciprocal.
287
+ r.post('/nodes/pair', mutGate, async (req, res) => {
288
+ const b = req.body || {};
289
+ if (!validName(b.name) || !nodesStore.parseSshTarget(b.ssh)) {
290
+ return send(res, 400, { error: 'name o alias SSH non valido' });
291
+ }
292
+ const pair = peering.parsePairingUrl(b.pairingUrl);
293
+ if (!pair) return send(res, 400, { error: 'link di pairing non valido' });
294
+ if (b.sshPort !== undefined && !nodesStore.isPort(b.sshPort)) return send(res, 400, { error: 'sshPort non valida' });
295
+ if (b.identityFile !== undefined && !nodesStore.isAbsPath(b.identityFile)) return send(res, 400, { error: 'identityFile non valido' });
296
+ let provisionalPort = null;
297
+ let rollbackCredential = null;
298
+ let created = false;
299
+ try {
300
+ let st = nodesStore.loadOrInitStore(nodesPath);
301
+ const localPort = nodesCmds.assignLocalPort(st);
302
+ provisionalPort = localPort;
303
+ const acceptToken = crypto.randomBytes(32).toString('base64url');
304
+ st = nodesStore.addNode(st, {
305
+ name: b.name, ssh: b.ssh, sshPort: b.sshPort,
306
+ remotePort: pair.port, localPort, identityFile: b.identityFile,
307
+ transport: 'auto', autostart: true, visibility: 'network',
308
+ direction: 'outbound', acceptToken,
309
+ });
310
+ nodesStore.atomicWriteStore(nodesPath, st);
311
+ created = true;
312
+ const node = nodesStore.getNode(st, b.name);
313
+ const started = nodesTunnel.startForward({
314
+ home, node, localAppPort: cfg.port,
315
+ spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
316
+ sshBin: seams.sshBin, logFd: seams.logFd,
317
+ });
318
+ if (!started.started && started.reason !== 'already running') throw new Error(started.reason || 'tunnel start failed');
319
+ if (typeof seams.pairDelay === 'function') await seams.pairDelay();
320
+ else await new Promise((resolve) => setTimeout(resolve, 900));
321
+ const fetchImpl = seams.fetchImpl || fetch;
322
+ const jr = await fetchImpl(`http://127.0.0.1:${localPort}/pair/join`, {
323
+ method: 'POST', headers: { 'content-type': 'application/json' },
324
+ body: JSON.stringify({
325
+ invite: pair.invite,
326
+ instanceId: st.nodeId,
327
+ name: String(b.localName || os.hostname()).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'node',
328
+ port: cfg.port,
329
+ acceptToken,
330
+ }),
331
+ });
332
+ const joined = await jr.json().catch(() => ({}));
333
+ if (!jr.ok || !nodesStore.validToken(joined.credential) || !nodesStore.isPort(joined.reversePort)
334
+ || !nodesStore.NODE_ID_RE.test(joined.instanceId)) throw new Error(joined.error || `pairing HTTP ${jr.status}`);
335
+ rollbackCredential = joined.credential;
336
+ st = nodesStore.loadOrInitStore(nodesPath);
337
+ st = nodesStore.updateNode(st, b.name, {
338
+ token: joined.credential, nodeId: joined.instanceId, reversePort: joined.reversePort,
339
+ });
340
+ nodesStore.atomicWriteStore(nodesPath, st);
341
+ nodesTunnel.stopTunnel({ home, name: b.name });
342
+ const finalStart = nodesTunnel.startForward({
343
+ home, node: nodesStore.getNode(st, b.name), localAppPort: cfg.port,
344
+ spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
345
+ sshBin: seams.sshBin, logFd: seams.logFd,
346
+ });
347
+ if (!finalStart.started && finalStart.reason !== 'already running') throw new Error(finalStart.reason || 'final tunnel start failed');
348
+ if (typeof seams.pairDelay === 'function') await seams.pairDelay();
349
+ else await new Promise((resolve) => setTimeout(resolve, 900));
350
+ let confirmed = null;
351
+ for (let attempt = 0; attempt < 3; attempt += 1) {
352
+ try {
353
+ confirmed = await fetchImpl(`http://127.0.0.1:${localPort}/pair/confirm`, {
354
+ method: 'POST', headers: { 'content-type': 'application/json' },
355
+ body: JSON.stringify({ credential: joined.credential }),
356
+ });
357
+ if (confirmed.ok) break;
358
+ } catch (_) {}
359
+ await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1)));
360
+ }
361
+ if (!confirmed || !confirmed.ok) {
362
+ const x = confirmed ? await confirmed.json().catch(() => ({})) : {};
363
+ throw new Error(x.error || `pair confirm HTTP ${confirmed ? confirmed.status : 'unreachable'}`);
364
+ }
365
+ send(res, 200, { paired: true, name: b.name, instanceId: joined.instanceId, transport: 'auto' });
366
+ } catch (e) {
367
+ if (rollbackCredential && provisionalPort) {
368
+ try {
369
+ const fetchImpl = seams.fetchImpl || fetch;
370
+ await fetchImpl(`http://127.0.0.1:${provisionalPort}/pair/cancel`, {
371
+ method: 'POST', headers: { 'content-type': 'application/json' },
372
+ body: JSON.stringify({ instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId, credential: rollbackCredential }),
373
+ });
374
+ } catch (_) {}
375
+ }
376
+ try {
377
+ const st = nodesStore.loadStore(nodesPath);
378
+ const n = st && nodesStore.getNode(st, b.name);
379
+ if (n && created) {
380
+ nodesTunnel.stopTunnel({ home, name: b.name });
381
+ nodesStore.atomicWriteStore(nodesPath, nodesStore.removeNode(st, b.name));
382
+ }
383
+ } catch (_) {}
384
+ send(res, 502, { error: String(e.message || e) });
385
+ }
386
+ });
387
+
388
+ // --- POST /nodes — nodes add (riusa nodesCmds.nodesAdd) --------------------
389
+ r.post('/nodes', mutGate, (req, res) => {
390
+ try {
391
+ const b = req.body;
392
+ if (!b || typeof b !== 'object' || Array.isArray(b)) {
393
+ return send(res, 400, { error: 'body deve essere un oggetto JSON' });
394
+ }
395
+ for (const k of Object.keys(b)) {
396
+ if (!ADD_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (schema: name, ssh, sshPort?, remotePort?, localPort?, keyPath?)` });
397
+ }
398
+ if (!validName(b.name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
399
+ if (!nodesStore.parseSsh(b.ssh)) return send(res, 400, { error: 'ssh non valido (atteso user@host strict)' });
400
+ if (b.sshPort !== undefined && !nodesStore.isPort(b.sshPort)) {
401
+ return send(res, 400, { error: 'sshPort deve essere un intero 1..65535' });
402
+ }
403
+ if (b.remotePort !== undefined && !nodesStore.isPort(b.remotePort)) {
404
+ return send(res, 400, { error: 'remotePort deve essere un intero 1..65535' });
405
+ }
406
+ if (b.localPort !== undefined && !nodesStore.isPort(b.localPort)) {
407
+ return send(res, 400, { error: 'localPort deve essere un intero 1..65535' });
408
+ }
409
+ if (b.keyPath !== undefined && !nodesStore.isAbsPath(b.keyPath)) {
410
+ return send(res, 400, { error: 'keyPath deve essere un path assoluto' });
411
+ }
412
+
413
+ const cap = capture();
414
+ const out = nodesCmds.nodesAdd({
415
+ ...cliOpts(cap),
416
+ name: b.name, ssh: b.ssh, sshPort: b.sshPort,
417
+ remotePort: b.remotePort, localPort: b.localPort, key: b.keyPath,
418
+ });
419
+ if (out.code === 0) {
420
+ return send(res, 200, {
421
+ added: true,
422
+ name: out.name,
423
+ sshPort: out.sshPort,
424
+ remotePort: out.remotePort,
425
+ localPort: out.localPort,
426
+ // La riga authorized_keys NON e' un segreto: pubkey con restrict/permitopen.
427
+ authorizedKeys: authorizedKeysLine(cap),
428
+ });
429
+ }
430
+ const msg = lastLine(cap, 'nodes add fallito');
431
+ if (out.reason === 'readonly') return send(res, 403, { error: msg });
432
+ if (out.reason === 'add rifiutato') {
433
+ // duplicato/self-reference = conflitto (409); schema rifiutato = 400.
434
+ const status = /duplicato|self-reference/.test(msg) ? 409 : 400;
435
+ return send(res, status, { error: msg });
436
+ }
437
+ // store invalido / keygen fallita / write fallita: errore lato server.
438
+ return send(res, 500, { error: msg });
439
+ } catch (e) { send(res, 500, { error: String(e.message || e) }); }
440
+ });
441
+
442
+ // --- DELETE /nodes/:name — nodes remove ------------------------------------
443
+ r.delete('/nodes/:name', mutGate, (req, res) => {
444
+ try {
445
+ const name = String(req.params.name || '');
446
+ if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
447
+ const cap = capture();
448
+ const out = nodesCmds.nodesRemove({ ...cliOpts(cap), name });
449
+ if (out.code === 0) return send(res, 200, { removed: true, name, stopped: !!out.stopped });
450
+ const msg = lastLine(cap, 'nodes remove fallito');
451
+ if (out.reason === 'readonly') return send(res, 403, { error: msg });
452
+ const status = /sconosciuto|nessun nodes\.json/.test(msg) ? 404 : 500;
453
+ send(res, status, { error: msg });
454
+ } catch (e) { send(res, 500, { error: String(e.message || e) }); }
455
+ });
456
+
457
+ // --- POST /nodes/:name/test — non-mutante (POST per coerenza azione) -------
458
+ // NON gated READONLY: e' una probe diagnostica, coerente col `nodes test` CLI.
459
+ r.post('/nodes/:name/test', async (req, res) => {
460
+ try {
461
+ const name = String(req.params.name || '');
462
+ if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
463
+ const cap = capture();
464
+ const out = await nodesCmds.nodesTest({ ...cliOpts(cap), name });
465
+ if (out.result === 'unknown-node') return send(res, 404, { error: `nodo sconosciuto "${name}"` });
466
+ // result distingue: ok | tunnel-down | health-ko | token-missing | token-ko
467
+ send(res, 200, { ok: out.result === 'ok', result: out.result, detail: lastLine(cap, '') });
468
+ } catch (e) { send(res, 500, { error: String(e.message || e) }); }
469
+ });
470
+
471
+ // --- POST /nodes/:name/up|down|restart — lifecycle tunnel ------------------
472
+ // GATE READONLY (audit F6, contract §4b(6): "restart processi tunnel" e' tra i
473
+ // mutanti della lista chiusa e NEXUSCREW_READONLY blocca TUTTI i mutanti). Prima
474
+ // questi endpoint erano esplicitamente NON gated ("decisione B0") — ma cio'
475
+ // contradiceva il contratto duro §4b(6): up/down/restart mutano processi e vanno
476
+ // bloccati in READONLY. /nodes/:name/test resta NON gated (probe diagnostica).
477
+ function lifecycleHandler(action) {
478
+ return (req, res) => {
479
+ try {
480
+ const name = String(req.params.name || '');
481
+ if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
482
+ const cap = capture();
483
+ const fn = action === 'up' ? nodesCmds.nodesUp
484
+ : action === 'down' ? nodesCmds.nodesDown
485
+ : nodesCmds.nodesRestart;
486
+ const out = fn({ ...cliOpts(cap), name });
487
+ if (out.code !== 0) {
488
+ const msg = lastLine(cap, `nodes ${action} fallito`);
489
+ const status = /sconosciuto/.test(msg) ? 404 : 500;
490
+ return send(res, status, { error: msg });
491
+ }
492
+ if (action === 'up') return send(res, 200, { name, started: out.started, pid: out.pid });
493
+ if (action === 'down') return send(res, 200, { name, stopped: out.stopped });
494
+ return send(res, 200, { name, restarted: true, pid: out.pid });
495
+ } catch (e) { send(res, 500, { error: String(e.message || e) }); }
496
+ };
497
+ }
498
+ r.post('/nodes/:name/up', mutGate, lifecycleHandler('up'));
499
+ r.post('/nodes/:name/down', mutGate, lifecycleHandler('down'));
500
+ r.post('/nodes/:name/restart', mutGate, lifecycleHandler('restart'));
501
+ r.patch('/nodes/:name/visibility', mutGate, (req, res) => {
502
+ try {
503
+ const name = String(req.params.name || '');
504
+ const visibility = req.body && req.body.visibility;
505
+ const selected = (req.body && req.body.selected) || [];
506
+ if (!validName(name) || !['network', 'relay-only', 'selected'].includes(visibility)) {
507
+ return send(res, 400, { error: 'visibility non valida' });
508
+ }
509
+ let st = nodesStore.loadOrInitStore(nodesPath);
510
+ st = nodesStore.updateNode(st, name, { visibility, selected: visibility === 'selected' ? selected : [] });
511
+ nodesStore.atomicWriteStore(nodesPath, st);
512
+ send(res, 200, { saved: true, name, visibility });
513
+ } catch (e) { send(res, 400, { error: String(e.message || e) }); }
514
+ });
515
+
516
+ // --- POST /node-role — node on/off (rendezvous config) ---------------------
517
+ r.post('/node-role', mutGate, (req, res) => {
518
+ try {
519
+ const b = req.body;
520
+ if (!b || typeof b !== 'object' || Array.isArray(b)) {
521
+ return send(res, 400, { error: 'body deve essere un oggetto JSON' });
522
+ }
523
+ for (const k of Object.keys(b)) {
524
+ if (!NODE_ROLE_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (schema: enabled, rendezvousSsh?, publishedPort?, keyPath?)` });
525
+ }
526
+ if (typeof b.enabled !== 'boolean') return send(res, 400, { error: 'enabled deve essere boolean' });
527
+ if (b.rendezvousSsh !== undefined && !nodesStore.parseSsh(b.rendezvousSsh)) {
528
+ return send(res, 400, { error: 'rendezvousSsh non valido (atteso user@host strict)' });
529
+ }
530
+ if (b.publishedPort !== undefined && !nodesStore.isPort(b.publishedPort)) {
531
+ return send(res, 400, { error: 'publishedPort deve essere un intero 1..65535' });
532
+ }
533
+ if (b.keyPath !== undefined && !nodesStore.isAbsPath(b.keyPath)) {
534
+ return send(res, 400, { error: 'keyPath deve essere un path assoluto' });
535
+ }
536
+
537
+ const cap = capture();
538
+ if (b.enabled === false) {
539
+ const out = nodesCmds.nodeOff({ ...cliOpts(cap) });
540
+ if (out.code === 0) return send(res, 200, { enabled: false, roles: out.roles });
541
+ return send(res, 500, { error: lastLine(cap, 'node off fallito') });
542
+ }
543
+ const out = nodesCmds.nodeOn({
544
+ ...cliOpts(cap),
545
+ rendezvousSsh: b.rendezvousSsh,
546
+ publishedPort: b.publishedPort,
547
+ key: b.keyPath,
548
+ port: cfg.port, // porta nexus locale da esporre = quella del server attivo
549
+ });
550
+ if (out.code === 0) {
551
+ const resp = { enabled: true, roles: out.roles, tunnel: out.tunnel || null };
552
+ const line = authorizedKeysLine(cap);
553
+ if (line) resp.authorizedKeys = line; // pubkey con permitlisten: non un segreto
554
+ return send(res, 200, resp);
555
+ }
556
+ const msg = lastLine(cap, 'node on fallito');
557
+ if (out.reason === 'readonly') return send(res, 403, { error: msg });
558
+ if (out.reason === 'no rendezvous') return send(res, 400, { error: msg });
559
+ if (out.reason === 'permitlisten') return send(res, 409, { error: msg });
560
+ send(res, 500, { error: msg });
561
+ } catch (e) { send(res, 500, { error: String(e.message || e) }); }
562
+ });
563
+
564
+ // --- POST /service/regenerate — rigenera l'unit service --------------------
565
+ // Riusa generateService/installService (lib/cli/service.js: escaping per-platform,
566
+ // no-symlink, tmp+rename atomico). L'execImpl e' un NO-OP che registra i comandi
567
+ // di attivazione SENZA eseguirli: il contratto B2 vieta il restart automatico
568
+ // dall'API (la UI avvisa di riavviare a mano).
569
+ r.post('/service/regenerate', mutGate, (_req, res) => {
570
+ try {
571
+ const { cfg: fileCfg } = readConfigFile(configPath);
572
+ const port = nodesStore.isPort(fileCfg.port) ? fileCfg.port : cfg.port;
573
+ const ctx = {
574
+ repoRoot: repoRoot(),
575
+ nodeBin: nodeBin(),
576
+ port, home,
577
+ uid: seams.uid || uid(),
578
+ installPath: seams.serviceInstallPath,
579
+ };
580
+ const content = generateService(platform, ctx);
581
+ const skipped = [];
582
+ const noExec = (bin, args) => { skipped.push(`${bin} ${(args || []).join(' ')}`); };
583
+ const out = installService(platform, content, ctx, { execImpl: noExec });
584
+ send(res, 200, {
585
+ regenerated: true,
586
+ target: out.target,
587
+ note: 'unit rigenerata; nessun restart automatico — riavvia il service per applicarla',
588
+ skippedActivation: skipped,
589
+ });
590
+ } catch (e) { send(res, 500, { error: String(e.message || e) }); }
591
+ });
592
+
593
+ // Error handler del router: body JSON malformato (express.json) -> 400 con causa;
594
+ // qualunque altro errore -> status coerente. MAI stack trace in risposta.
595
+ // eslint-disable-next-line no-unused-vars
596
+ r.use((err, _req, res, _next) => {
597
+ if (err && err.type === 'entity.parse.failed') {
598
+ return send(res, 400, { error: 'body JSON non valido' });
599
+ }
600
+ if (err && err.type === 'entity.too.large') {
601
+ return send(res, 400, { error: 'body troppo grande (limite 8kb)' });
602
+ }
603
+ send(res, (err && err.status) || 500, { error: String((err && err.message) || err) });
604
+ });
605
+
606
+ return r;
607
+ }
608
+
609
+ // Public only because the one-time invite itself is the capability. The route
610
+ // exposes no generic API and creates a scoped peer credential, never a UI token.
611
+ function publicPeeringRoutes(deps = {}) {
612
+ const cfg = deps.cfg || {};
613
+ const home = cfg.home || os.homedir();
614
+ const nodesPath = deps.nodesPath || cfg.nodesPath || nodesStore.defaultNodesPath(home);
615
+ const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
616
+ const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
617
+ const r = express.Router();
618
+ const attempts = new Map();
619
+ r.use(express.json({ limit: '8kb' }));
620
+ r.post('/join', (req, res) => {
621
+ const key = String(req.socket && req.socket.remoteAddress || 'local');
622
+ const now = Date.now();
623
+ const recent = (attempts.get(key) || []).filter((x) => now - x < 60_000);
624
+ recent.push(now); attempts.set(key, recent);
625
+ if (recent.length > 10) return res.status(429).json({ error: 'troppi tentativi di pairing' });
626
+ const b = req.body || {};
627
+ if (!nodesStore.validToken(b.invite) || !nodesStore.NODE_ID_RE.test(b.instanceId)
628
+ || !validPeerName(b.name) || !nodesStore.isPort(b.port) || !nodesStore.validToken(b.acceptToken)) {
629
+ return res.status(400).json({ error: 'pairing request non valida' });
630
+ }
631
+ if (!peering.consumeInvite({ invitesPath, invite: b.invite })) return res.status(410).json({ error: 'invito scaduto o gia usato' });
632
+ try {
633
+ const st = nodesStore.loadOrInitStore(nodesPath);
634
+ if (st.nodeId === b.instanceId || st.nodes.some((n) => n.nodeId === b.instanceId)) return res.status(409).json({ error: 'peer duplicato' });
635
+ let name = b.name;
636
+ for (let i = 2; nodesStore.getNode(st, name); i += 1) name = `${b.name.slice(0, 28)}-${i}`;
637
+ const reversePort = peering.allocateReversePort(st.nodes);
638
+ const credential = peering.createPending({ pendingPath, data: {
639
+ name, remotePort: b.port, reversePort, instanceId: b.instanceId, acceptToken: b.acceptToken,
640
+ } });
641
+ res.json({ paired: true, instanceId: st.nodeId, reversePort, credential });
642
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
643
+ });
644
+ r.post('/confirm', (req, res) => {
645
+ const b = req.body || {};
646
+ if (!nodesStore.validToken(b.credential)) return res.status(400).json({ error: 'confirm non valido' });
647
+ const pending = peering.consumePending({ pendingPath, credential: b.credential });
648
+ if (!pending) {
649
+ const st = nodesStore.loadStore(nodesPath);
650
+ if (st && st.nodes.some((n) => n.acceptToken && peering.safeEqual(n.acceptToken, b.credential))) return res.json({ confirmed: true, idempotent: true });
651
+ return res.status(410).json({ error: 'pairing pending scaduto o gia usato' });
652
+ }
653
+ try {
654
+ let st = nodesStore.loadOrInitStore(nodesPath);
655
+ if (st.nodeId === pending.instanceId || st.nodes.some((n) => n.nodeId === pending.instanceId)) return res.status(409).json({ error: 'peer duplicato' });
656
+ st = nodesStore.addNode(st, {
657
+ name: pending.name, remotePort: pending.remotePort, localPort: pending.reversePort,
658
+ direction: 'inbound', transport: 'inbound', autostart: true,
659
+ visibility: 'network', nodeId: pending.instanceId,
660
+ token: pending.acceptToken, acceptToken: b.credential,
661
+ });
662
+ nodesStore.atomicWriteStore(nodesPath, st);
663
+ res.json({ confirmed: true });
664
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
665
+ });
666
+ r.post('/cancel', (req, res) => {
667
+ const b = req.body || {};
668
+ if (!nodesStore.NODE_ID_RE.test(b.instanceId) || !nodesStore.validToken(b.credential)) return res.status(400).json({ error: 'cancel non valido' });
669
+ try {
670
+ const pending = peering.consumePending({ pendingPath, credential: b.credential });
671
+ if (!pending || pending.instanceId !== b.instanceId) {
672
+ const st = nodesStore.loadStore(nodesPath);
673
+ const peer = st && st.nodes.find((n) => n.nodeId === b.instanceId && n.acceptToken && peering.safeEqual(n.acceptToken, b.credential));
674
+ if (!peer) return res.status(404).json({ error: 'pair non trovato' });
675
+ nodesStore.atomicWriteStore(nodesPath, nodesStore.removeNode(st, peer.name));
676
+ }
677
+ res.json({ cancelled: true });
678
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
679
+ });
680
+ return r;
681
+ }
682
+
683
+ function validPeerName(name) { return typeof name === 'string' && nodesStore.NODE_NAME_RE.test(name); }
684
+
685
+ module.exports = { settingsRoutes, publicPeeringRoutes, deepScrubTokens };