@mmmbuto/nexuscrew 0.8.15 → 0.8.17
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/README.md +280 -396
- package/frontend/dist/assets/index-CffPTRyq.js +91 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +11 -263
- package/lib/cli/fleet-service.js +4 -0
- package/lib/cli/runtime-lifecycle.js +299 -0
- package/lib/cli/service.js +4 -1
- package/lib/fleet/builtin.js +56 -434
- package/lib/fleet/launch.js +227 -0
- package/lib/fleet/runtime.js +269 -0
- package/lib/mcp/cells.js +154 -0
- package/lib/mcp/server.js +11 -422
- package/lib/mcp/tools.js +300 -0
- package/lib/nodes/tunnel-supervisor.js +65 -6
- package/lib/nodes/tunnel.js +49 -6
- package/lib/runtime/env.js +38 -1
- package/lib/server.js +8 -0
- package/lib/settings/pairing-coordinator.js +325 -0
- package/lib/settings/public-peering-routes.js +126 -0
- package/lib/settings/routes.js +16 -396
- package/package.json +1 -1
- package/frontend/dist/assets/index-BD-7qEsn.js +0 -91
package/lib/mcp/tools.js
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Registro TOOLS del server MCP (`lib/mcp/server.js`) + helper di validazione
|
|
3
|
+
// input/sessione fail-closed.
|
|
4
|
+
//
|
|
5
|
+
// Ogni tool e' prefissato `nc_` (anti-collisione) ed espone name/description/
|
|
6
|
+
// inputSchema/annotations (readOnlyHint sui tool read-only) + handler async.
|
|
7
|
+
// Gli handler ricevono `ctx` (session/api/home/fileExists/messageId) costruito
|
|
8
|
+
// dal server: qui NESSUN transport diretto, solo validazione bounded degli
|
|
9
|
+
// argomenti e orchestrazione delle chiamate via ctx.api. Ordine, nomi, schemi,
|
|
10
|
+
// handler, identity gate e semantica read/write sono preservati EXACT.
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const {
|
|
13
|
+
NODE_ID_RE, orderedDeckMembers, fleetStatusPath, fleetCellsBySession,
|
|
14
|
+
routePath, topologyOwners, memberOwnerId, parseCellTarget, readCellDirectory,
|
|
15
|
+
} = require('./cells.js');
|
|
16
|
+
|
|
17
|
+
// --- helper input tool (fail-closed) ------------------------------------------
|
|
18
|
+
function argString(args, key, { required = false, max = 4096 } = {}) {
|
|
19
|
+
const v = args[key];
|
|
20
|
+
if (v === undefined || v === null) {
|
|
21
|
+
if (required) throw new Error(`parametro "${key}" obbligatorio`);
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
if (typeof v !== 'string') throw new Error(`parametro "${key}" deve essere una stringa`);
|
|
25
|
+
if (required && !v.trim()) throw new Error(`parametro "${key}" non puo' essere vuoto`);
|
|
26
|
+
if (v.length > max) throw new Error(`parametro "${key}" troppo lungo (max ${max})`);
|
|
27
|
+
return v;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function requireSession(session, tool) {
|
|
31
|
+
if (session) return session;
|
|
32
|
+
throw new Error(
|
|
33
|
+
`${tool}: sessione tmux non identificata — serve $TMUX (dentro tmux) o NEXUSCREW_MCP_SESSION`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// --- definizione tool (prefisso nc_ anti-collisione) ---------------------------
|
|
38
|
+
// annotations.readOnlyHint:true sui tool che non mutano nulla (§1).
|
|
39
|
+
const TOOLS = [
|
|
40
|
+
{
|
|
41
|
+
name: 'nc_notify',
|
|
42
|
+
description: 'Invia una notifica all\'operatore via NexusCrew (UI aperte + web push). Usala per esiti, avvisi e richieste di attenzione non bloccanti.',
|
|
43
|
+
inputSchema: {
|
|
44
|
+
type: 'object',
|
|
45
|
+
properties: {
|
|
46
|
+
title: { type: 'string', description: 'titolo breve della notifica' },
|
|
47
|
+
body: { type: 'string', description: 'dettaglio opzionale' },
|
|
48
|
+
urgency: { type: 'string', enum: ['normal', 'high'], description: 'default normal' },
|
|
49
|
+
},
|
|
50
|
+
required: ['title'],
|
|
51
|
+
},
|
|
52
|
+
async handler(args, ctx) {
|
|
53
|
+
const title = argString(args, 'title', { required: true, max: 200 });
|
|
54
|
+
const body = argString(args, 'body', { max: 2000 });
|
|
55
|
+
const urgency = argString(args, 'urgency', { max: 16 });
|
|
56
|
+
if (urgency !== undefined && urgency !== 'normal' && urgency !== 'high') {
|
|
57
|
+
throw new Error('urgency deve essere "normal" o "high"');
|
|
58
|
+
}
|
|
59
|
+
const session = await ctx.session();
|
|
60
|
+
const payload = { title, ...(body ? { body } : {}), ...(urgency ? { urgency } : {}) };
|
|
61
|
+
if (session) payload.session = session;
|
|
62
|
+
const j = await ctx.api('POST', '/api/notify', payload);
|
|
63
|
+
return { delivered: j.delivered };
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: 'nc_ask',
|
|
68
|
+
description: 'Pone una domanda all\'operatore e ritorna SUBITO (non bloccare in attesa): la risposta arrivera\' come messaggio incollato in questa sessione tmux, prefissato [human reply · ask#<id>] per default.',
|
|
69
|
+
inputSchema: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
properties: {
|
|
72
|
+
question: { type: 'string', description: 'la domanda per l\'operatore' },
|
|
73
|
+
options: { type: 'array', items: { type: 'string' }, description: 'opzioni di risposta rapida (max 8)' },
|
|
74
|
+
},
|
|
75
|
+
required: ['question'],
|
|
76
|
+
},
|
|
77
|
+
async handler(args, ctx) {
|
|
78
|
+
const question = argString(args, 'question', { required: true, max: 2000 });
|
|
79
|
+
if (args.options !== undefined && !Array.isArray(args.options)) {
|
|
80
|
+
throw new Error('options deve essere un array di stringhe');
|
|
81
|
+
}
|
|
82
|
+
const session = requireSession(await ctx.session(), 'nc_ask');
|
|
83
|
+
const j = await ctx.api('POST', '/api/asks', {
|
|
84
|
+
question, ...(args.options ? { options: args.options } : {}), session,
|
|
85
|
+
});
|
|
86
|
+
return {
|
|
87
|
+
askId: j.id,
|
|
88
|
+
note: 'la risposta dell\'operatore arrivera\' come messaggio incollato in questa sessione tmux',
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
name: 'nc_send_file',
|
|
94
|
+
description: 'Consegna un file all\'operatore: lo copia nell\'outbox NexusCrew di questa sessione (badge + notifica automatici). Path assoluto sotto HOME.',
|
|
95
|
+
inputSchema: {
|
|
96
|
+
type: 'object',
|
|
97
|
+
properties: {
|
|
98
|
+
path: { type: 'string', description: 'path assoluto del file (sotto HOME)' },
|
|
99
|
+
caption: { type: 'string', description: 'didascalia opzionale' },
|
|
100
|
+
},
|
|
101
|
+
required: ['path'],
|
|
102
|
+
},
|
|
103
|
+
async handler(args, ctx) {
|
|
104
|
+
const p = argString(args, 'path', { required: true, max: 4096 });
|
|
105
|
+
const caption = argString(args, 'caption', { max: 500 });
|
|
106
|
+
if (!path.isAbsolute(p)) throw new Error('path deve essere assoluto');
|
|
107
|
+
// Pre-check locale (errore immediato e chiaro); la validazione autoritativa
|
|
108
|
+
// (realpath sotto HOME, file regolare) la rifa' comunque il server.
|
|
109
|
+
if (!ctx.fileExists(p)) throw new Error(`file inesistente: ${p}`);
|
|
110
|
+
const home = ctx.home();
|
|
111
|
+
if (p !== home && !p.startsWith(home + path.sep)) throw new Error('path fuori da HOME');
|
|
112
|
+
const session = requireSession(await ctx.session(), 'nc_send_file');
|
|
113
|
+
const j = await ctx.api('POST', '/api/files/outbox', {
|
|
114
|
+
session, path: p, ...(caption ? { caption } : {}),
|
|
115
|
+
});
|
|
116
|
+
return { name: j.name, box: 'outbox' };
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: 'nc_status',
|
|
121
|
+
description: 'Stato read-only di NexusCrew: sessioni tmux vive e celle della flotta (se abilitata).',
|
|
122
|
+
inputSchema: { type: 'object', properties: {} },
|
|
123
|
+
annotations: { readOnlyHint: true },
|
|
124
|
+
async handler(_args, ctx) {
|
|
125
|
+
const s = await ctx.api('GET', '/api/sessions');
|
|
126
|
+
const sessions = (Array.isArray(s.sessions) ? s.sessions : [])
|
|
127
|
+
.map((x) => ({ name: x.name, active: !!x.attached }));
|
|
128
|
+
let fleet = null;
|
|
129
|
+
try {
|
|
130
|
+
const f = await ctx.api('GET', '/api/fleet/status');
|
|
131
|
+
if (f && f.available) {
|
|
132
|
+
fleet = {
|
|
133
|
+
cells: (Array.isArray(f.cells) ? f.cells : []).map((c) => ({
|
|
134
|
+
cell: c.cell, session: c.tmuxSession, engine: c.engine, active: !!c.active,
|
|
135
|
+
})),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
} catch (_) { /* fleet opzionale: assente/disabilitata -> null */ }
|
|
139
|
+
return { sessions, fleet };
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: 'nc_deck',
|
|
144
|
+
description: 'Contesto read-only del deck della sessione chiamante: restituisce i deck che la contengono e i relativi membri con nome cella Fleet, sessione tmux e route.',
|
|
145
|
+
inputSchema: { type: 'object', properties: {} },
|
|
146
|
+
annotations: { readOnlyHint: true },
|
|
147
|
+
async handler(_args, ctx) {
|
|
148
|
+
const tmuxSession = requireSession(await ctx.session(), 'nc_deck');
|
|
149
|
+
const [config, topology, localDecks] = await Promise.all([
|
|
150
|
+
ctx.api('GET', '/api/config'), ctx.api('GET', '/api/topology'), ctx.api('GET', '/api/decks'),
|
|
151
|
+
]);
|
|
152
|
+
const localNodeId = String(config && config.instanceId || '');
|
|
153
|
+
if (!NODE_ID_RE.test(localNodeId)) throw new Error('instanceId locale non disponibile');
|
|
154
|
+
if (!localDecks || !Array.isArray(localDecks.decks)) throw new Error('risposta deck non valida');
|
|
155
|
+
|
|
156
|
+
const remotes = topologyOwners(topology).filter((owner) => !owner.stale && owner.instanceId !== localNodeId);
|
|
157
|
+
const viewerById = new Map([[localNodeId, []], ...remotes.map((owner) => [owner.instanceId, owner.route])]);
|
|
158
|
+
const sources = [{
|
|
159
|
+
owner: { instanceId: localNodeId, route: [], label: 'Local' },
|
|
160
|
+
ownerTopology: topologyOwners(topology), decks: localDecks.decks,
|
|
161
|
+
}];
|
|
162
|
+
await Promise.all(remotes.map(async (owner) => {
|
|
163
|
+
const decksPath = routePath(owner.route, 'decks');
|
|
164
|
+
const topologyPath = routePath(owner.route, 'topology');
|
|
165
|
+
if (!decksPath || !topologyPath) return;
|
|
166
|
+
try {
|
|
167
|
+
const [deckPayload, ownerTopology] = await Promise.all([
|
|
168
|
+
ctx.api('GET', decksPath), ctx.api('GET', topologyPath).catch(() => ({ nodes: [] })),
|
|
169
|
+
]);
|
|
170
|
+
if (deckPayload && Array.isArray(deckPayload.decks)) {
|
|
171
|
+
sources.push({ owner, ownerTopology: topologyOwners(ownerTopology), decks: deckPayload.decks });
|
|
172
|
+
}
|
|
173
|
+
} catch (_) { /* owner offline/withdrawn: no stale deck disclosure */ }
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
const decks = [];
|
|
177
|
+
for (const source of sources) {
|
|
178
|
+
for (const deck of source.decks) {
|
|
179
|
+
if (!deck || typeof deck.name !== 'string') continue;
|
|
180
|
+
const members = orderedDeckMembers(deck).map((member) => ({
|
|
181
|
+
...member,
|
|
182
|
+
ownerId: memberOwnerId(member, source.owner, source.ownerTopology),
|
|
183
|
+
}));
|
|
184
|
+
if (!members.some((member) => member.ownerId === localNodeId && member.tmuxSession === tmuxSession)) continue;
|
|
185
|
+
decks.push({
|
|
186
|
+
id: `${source.owner.instanceId}:${deck.name}`,
|
|
187
|
+
name: deck.name,
|
|
188
|
+
owner: { instanceId: source.owner.instanceId, route: source.owner.route.length ? source.owner.route.join('/') : 'local', label: source.owner.label },
|
|
189
|
+
members,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
decks.sort((a, b) => a.owner.route.localeCompare(b.owner.route) || a.name.localeCompare(b.name));
|
|
194
|
+
|
|
195
|
+
if (!decks.length) return { tmuxSession, nodeId: localNodeId, decks: [] };
|
|
196
|
+
|
|
197
|
+
const routes = new Set(['']);
|
|
198
|
+
for (const deck of decks) for (const member of deck.members) {
|
|
199
|
+
const route = member.ownerId && viewerById.has(member.ownerId)
|
|
200
|
+
? viewerById.get(member.ownerId).join('/') : null;
|
|
201
|
+
member.viewerRoute = route;
|
|
202
|
+
if (route !== null) routes.add(route);
|
|
203
|
+
}
|
|
204
|
+
const cellsByRoute = new Map();
|
|
205
|
+
await Promise.all([...routes].map(async (route) => {
|
|
206
|
+
const apiPath = fleetStatusPath(route);
|
|
207
|
+
if (!apiPath) return;
|
|
208
|
+
try { cellsByRoute.set(route, fleetCellsBySession(await ctx.api('GET', apiPath))); }
|
|
209
|
+
catch (_) { cellsByRoute.set(route, new Map()); }
|
|
210
|
+
}));
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
tmuxSession, nodeId: localNodeId,
|
|
214
|
+
decks: decks.map((deck) => ({
|
|
215
|
+
id: deck.id, name: deck.name, owner: deck.owner,
|
|
216
|
+
members: deck.members.map((member) => {
|
|
217
|
+
const route = member.viewerRoute;
|
|
218
|
+
return {
|
|
219
|
+
cell: route === null ? null : (cellsByRoute.get(route)?.get(member.tmuxSession) || null),
|
|
220
|
+
tmuxSession: member.tmuxSession,
|
|
221
|
+
ownerId: member.ownerId,
|
|
222
|
+
route: route === null ? 'unavailable' : (route || 'local'),
|
|
223
|
+
self: member.ownerId === localNodeId && member.tmuxSession === tmuxSession,
|
|
224
|
+
};
|
|
225
|
+
}),
|
|
226
|
+
})),
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
name: 'nc_cells',
|
|
232
|
+
description: 'Directory read-only di tutte le celle Fleet autorizzate nella rete NexusCrew. Usa l\'id owner-qualified restituito per evitare nomi ambigui.',
|
|
233
|
+
inputSchema: { type: 'object', properties: {} },
|
|
234
|
+
annotations: { readOnlyHint: true },
|
|
235
|
+
async handler(_args, ctx) {
|
|
236
|
+
const callerSession = await ctx.session();
|
|
237
|
+
return readCellDirectory(ctx, callerSession);
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
name: 'nc_send_cell',
|
|
242
|
+
description: 'Invia e sottopone un messaggio a una cella Fleet attiva autorizzata. target deve essere l\'id esatto restituito da nc_cells; submitted non significa lavoro completato.',
|
|
243
|
+
inputSchema: {
|
|
244
|
+
type: 'object',
|
|
245
|
+
properties: {
|
|
246
|
+
target: { type: 'string', description: 'id owner-qualified restituito da nc_cells: <instanceId>:<cell>' },
|
|
247
|
+
message: { type: 'string', description: 'messaggio o task da sottoporre (max 8000 caratteri)' },
|
|
248
|
+
},
|
|
249
|
+
required: ['target', 'message'],
|
|
250
|
+
},
|
|
251
|
+
async handler(args, ctx) {
|
|
252
|
+
const targetRef = parseCellTarget(argString(args, 'target', { required: true, max: 128 }));
|
|
253
|
+
if (!targetRef) throw new Error('target non valido: usa l\'id esatto restituito da nc_cells');
|
|
254
|
+
const message = argString(args, 'message', { required: true, max: 8000 });
|
|
255
|
+
for (let i = 0; i < message.length; i += 1) {
|
|
256
|
+
const code = message.charCodeAt(i);
|
|
257
|
+
if (code === 9 || code === 10 || code === 13) continue;
|
|
258
|
+
if (code < 32 || code === 127) throw new Error('message contiene caratteri di controllo non ammessi');
|
|
259
|
+
}
|
|
260
|
+
const callerSession = requireSession(await ctx.session(), 'nc_send_cell');
|
|
261
|
+
const directory = await readCellDirectory(ctx, callerSession);
|
|
262
|
+
const sender = directory.cells.find((cell) => cell.self && cell.active);
|
|
263
|
+
if (!sender) throw new Error('nc_send_cell: la sessione chiamante non e\' una cella Fleet attiva locale');
|
|
264
|
+
const target = directory.cells.find((cell) => cell.instanceId === targetRef.instanceId
|
|
265
|
+
&& cell.cell === targetRef.cell);
|
|
266
|
+
if (!target) throw new Error('cella destinataria non trovata nella rete autorizzata');
|
|
267
|
+
if (!target.canReceive) throw new Error('cella destinataria non attiva; nessun messaggio accodato');
|
|
268
|
+
const apiPath = target.route === 'local'
|
|
269
|
+
? '/api/cells/send' : routePath(target.route.split('/'), 'cells/send');
|
|
270
|
+
if (!apiPath) throw new Error('route destinataria non valida');
|
|
271
|
+
const id = ctx.messageId();
|
|
272
|
+
const receipt = await ctx.api('POST', apiPath, {
|
|
273
|
+
id,
|
|
274
|
+
from: { instanceId: sender.instanceId, cell: sender.cell, tmuxSession: sender.tmuxSession },
|
|
275
|
+
to: { instanceId: target.instanceId, cell: target.cell, tmuxSession: target.tmuxSession },
|
|
276
|
+
message,
|
|
277
|
+
});
|
|
278
|
+
return {
|
|
279
|
+
id: receipt.id,
|
|
280
|
+
status: receipt.status,
|
|
281
|
+
at: receipt.at,
|
|
282
|
+
to: receipt.to,
|
|
283
|
+
note: receipt.note || 'submitted conferma il trasporto, non il completamento del task',
|
|
284
|
+
};
|
|
285
|
+
},
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
name: 'nc_inbox',
|
|
289
|
+
description: 'Elenca i file ricevuti nell\'inbox NexusCrew di questa sessione (read-only).',
|
|
290
|
+
inputSchema: { type: 'object', properties: {} },
|
|
291
|
+
annotations: { readOnlyHint: true },
|
|
292
|
+
async handler(_args, ctx) {
|
|
293
|
+
const session = requireSession(await ctx.session(), 'nc_inbox');
|
|
294
|
+
const j = await ctx.api('GET', `/api/files?session=${encodeURIComponent(session)}`);
|
|
295
|
+
return { inbox: Array.isArray(j.inbox) ? j.inbox : [] };
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
module.exports = { TOOLS, argString, requireSession };
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Detached supervisor for one SSH tunnel. The parent NexusCrew process can exit;
|
|
4
4
|
// this process keeps the tunnel alive and retries failures with bounded backoff.
|
|
5
5
|
const fs = require('node:fs');
|
|
6
|
+
const net = require('node:net');
|
|
6
7
|
const path = require('node:path');
|
|
7
8
|
const { spawn } = require('node:child_process');
|
|
8
9
|
const { backoffDelay } = require('./tunnel.js');
|
|
@@ -24,13 +25,70 @@ let stopping = false;
|
|
|
24
25
|
let attempt = 0;
|
|
25
26
|
let retryTimer = null;
|
|
26
27
|
let upTimer = null;
|
|
28
|
+
let forwardProbeTimer = null;
|
|
29
|
+
let forwardSocket = null;
|
|
27
30
|
let ownershipWaitTimer = null;
|
|
28
31
|
let ownershipTimer = null;
|
|
29
32
|
|
|
33
|
+
function localForwardPort(args) {
|
|
34
|
+
for (let i = 0; i < args.length - 1; i += 1) {
|
|
35
|
+
if (args[i] !== '-L') continue;
|
|
36
|
+
const match = String(args[i + 1] || '').match(/^127\.0\.0\.1:(\d+):127\.0\.0\.1:\d+$/);
|
|
37
|
+
const port = match ? Number(match[1]) : 0;
|
|
38
|
+
if (Number.isInteger(port) && port >= 1 && port <= 65535) return port;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const forwardPort = localForwardPort(sshArgs);
|
|
44
|
+
|
|
30
45
|
function logEvent(message) {
|
|
31
46
|
try { process.stderr.write(`[nexuscrew] ${String(message).replace(/[\r\n]+/g, ' ')}\n`); } catch (_) {}
|
|
32
47
|
}
|
|
33
48
|
|
|
49
|
+
function clearForwardProbe() {
|
|
50
|
+
clearTimeout(forwardProbeTimer);
|
|
51
|
+
forwardProbeTimer = null;
|
|
52
|
+
if (forwardSocket) {
|
|
53
|
+
try { forwardSocket.destroy(); } catch (_) {}
|
|
54
|
+
forwardSocket = null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A live ssh PID is not proof of authentication: the process may still be
|
|
59
|
+
// blocked connecting to an unreachable endpoint. Opening the local -L socket
|
|
60
|
+
// forces OpenSSH to establish the real forward channel. Only that event may
|
|
61
|
+
// advertise transport-ready or reset retry backoff.
|
|
62
|
+
function probeForward(expectedChild) {
|
|
63
|
+
if (stopping || child !== expectedChild || !child || child.exitCode != null) return;
|
|
64
|
+
if (!forwardPort) {
|
|
65
|
+
writeState('transport-probing', { sshPid: child.pid, detail: 'local forward unavailable' });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
let settled = false;
|
|
69
|
+
const socket = net.connect({ host: '127.0.0.1', port: forwardPort });
|
|
70
|
+
forwardSocket = socket;
|
|
71
|
+
const done = (ready) => {
|
|
72
|
+
if (settled) return;
|
|
73
|
+
settled = true;
|
|
74
|
+
try { socket.destroy(); } catch (_) {}
|
|
75
|
+
if (forwardSocket === socket) forwardSocket = null;
|
|
76
|
+
if (stopping || child !== expectedChild || !child || child.exitCode != null) return;
|
|
77
|
+
if (ready) {
|
|
78
|
+
attempt = 0;
|
|
79
|
+
logEvent(`forward ready stableMs=${stableMs}`);
|
|
80
|
+
if (!writeState('transport-ready', { sshPid: child.pid, stableMs, probe: 'tcp-forward' })) stop();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!writeState('transport-probing', { sshPid: child.pid, stableMs })) return stop();
|
|
84
|
+
forwardProbeTimer = setTimeout(() => probeForward(expectedChild), 250);
|
|
85
|
+
};
|
|
86
|
+
socket.setTimeout(1000);
|
|
87
|
+
socket.once('connect', () => done(true));
|
|
88
|
+
socket.once('error', () => done(false));
|
|
89
|
+
socket.once('timeout', () => done(false));
|
|
90
|
+
}
|
|
91
|
+
|
|
34
92
|
function ownsGeneration() {
|
|
35
93
|
try {
|
|
36
94
|
const meta = JSON.parse(fs.readFileSync(pidPath, 'utf8'));
|
|
@@ -79,19 +137,18 @@ function run() {
|
|
|
79
137
|
if (failureHandled) return;
|
|
80
138
|
failureHandled = true;
|
|
81
139
|
clearTimeout(upTimer);
|
|
140
|
+
clearForwardProbe();
|
|
82
141
|
child = null;
|
|
83
142
|
scheduleRetry(detail);
|
|
84
143
|
};
|
|
85
144
|
child.once('spawn', () => {
|
|
86
145
|
logEvent(`ssh attempt=${attempt + 1} spawned`);
|
|
87
|
-
// ExitOnForwardFailure
|
|
88
|
-
//
|
|
89
|
-
//
|
|
146
|
+
// ExitOnForwardFailure only proves that the local bind was accepted. It
|
|
147
|
+
// does not prove authentication or remote reachability, so after the
|
|
148
|
+
// stability window require a real TCP open through the -L channel.
|
|
90
149
|
upTimer = setTimeout(() => {
|
|
91
150
|
if (!stopping && child && child.exitCode == null) {
|
|
92
|
-
|
|
93
|
-
logEvent(`transport ready stableMs=${stableMs}`);
|
|
94
|
-
if (!writeState('transport-ready', { sshPid: child.pid, stableMs })) stop();
|
|
151
|
+
probeForward(child);
|
|
95
152
|
}
|
|
96
153
|
}, stableMs);
|
|
97
154
|
});
|
|
@@ -109,6 +166,7 @@ function run() {
|
|
|
109
166
|
function finish() {
|
|
110
167
|
clearTimeout(retryTimer);
|
|
111
168
|
clearTimeout(upTimer);
|
|
169
|
+
clearForwardProbe();
|
|
112
170
|
clearTimeout(ownershipWaitTimer);
|
|
113
171
|
clearInterval(ownershipTimer);
|
|
114
172
|
if (ownsGeneration()) {
|
|
@@ -126,6 +184,7 @@ function stop() {
|
|
|
126
184
|
stopping = true;
|
|
127
185
|
clearTimeout(retryTimer);
|
|
128
186
|
clearTimeout(upTimer);
|
|
187
|
+
clearForwardProbe();
|
|
129
188
|
if (child && child.exitCode == null) {
|
|
130
189
|
try { child.kill('SIGTERM'); } catch (_) {}
|
|
131
190
|
setTimeout(() => { try { if (child && child.exitCode == null) child.kill('SIGKILL'); } catch (_) {} finish(); }, 1500).unref();
|
package/lib/nodes/tunnel.js
CHANGED
|
@@ -28,6 +28,7 @@ const SSH_BASE_OPTS = Object.freeze([
|
|
|
28
28
|
'-o', 'ServerAliveInterval=30',
|
|
29
29
|
'-o', 'ServerAliveCountMax=3',
|
|
30
30
|
'-o', 'BatchMode=yes',
|
|
31
|
+
'-o', 'ConnectTimeout=15',
|
|
31
32
|
// A Host stanza may set LogLevel=QUIET, leaving the per-tunnel diagnostic
|
|
32
33
|
// completely empty even for bind/auth/forward failures. ERROR is still
|
|
33
34
|
// quiet on success and never logs key material, but preserves real failures.
|
|
@@ -101,6 +102,49 @@ function tunnelLogPath(home, name) {
|
|
|
101
102
|
return path.join(tunnelDir(home), `${name}.log`);
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
// Cleanup identifier for pre-0.8.10 standalone rendezvous supervisors. New
|
|
106
|
+
// runtimes never start this second connection; reconciliation can still stop a
|
|
107
|
+
// stale legacy process left by an older install.
|
|
108
|
+
const REVERSE_NAME = '__rendezvous__';
|
|
109
|
+
|
|
110
|
+
// Reconcile detached supervisors against the authoritative node store. A
|
|
111
|
+
// server crash or an older rollback could leave a valid supervisor pidfile
|
|
112
|
+
// after its node disappeared; startup and `nexuscrew stop` must recover it
|
|
113
|
+
// without broad process matching. Only strict NexusCrew pidfile names are
|
|
114
|
+
// considered and stopTunnel still performs pid+cmd verification.
|
|
115
|
+
function tunnelPidNames(home) {
|
|
116
|
+
const dir = tunnelDir(home);
|
|
117
|
+
try {
|
|
118
|
+
const root = fs.lstatSync(dir);
|
|
119
|
+
if (root.isSymbolicLink() || !root.isDirectory()) return [];
|
|
120
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
121
|
+
.filter((entry) => entry.isFile() && !entry.isSymbolicLink() && entry.name.endsWith('.pid'))
|
|
122
|
+
.map((entry) => entry.name.slice(0, -4))
|
|
123
|
+
.filter((name) => store.NODE_NAME_RE.test(name) || name === REVERSE_NAME)
|
|
124
|
+
.sort();
|
|
125
|
+
} catch (_) { return []; }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function reconcileTunnelSupervisors({ home = os.homedir(), configuredNames = [], stopImpl } = {}) {
|
|
129
|
+
const keep = new Set((Array.isArray(configuredNames) ? configuredNames : [])
|
|
130
|
+
.filter((name) => store.NODE_NAME_RE.test(String(name || ''))));
|
|
131
|
+
const stopOne = typeof stopImpl === 'function' ? stopImpl : stopTunnel;
|
|
132
|
+
const result = { kept: [], stopped: [], cleaned: [], failed: [] };
|
|
133
|
+
const safeAbsent = new Set(['no pidfile', 'stale (pid dead)', 'pid reuse (cmd mismatch)']);
|
|
134
|
+
for (const name of tunnelPidNames(home)) {
|
|
135
|
+
if (keep.has(name)) { result.kept.push(name); continue; }
|
|
136
|
+
try {
|
|
137
|
+
const out = stopOne({ home, name });
|
|
138
|
+
if (out && out.stopped) result.stopped.push(name);
|
|
139
|
+
else if (out && safeAbsent.has(out.reason)) result.cleaned.push(name);
|
|
140
|
+
else result.failed.push({ name, reason: (out && out.reason) || 'stop failed' });
|
|
141
|
+
} catch (error) {
|
|
142
|
+
result.failed.push({ name, reason: String((error && error.message) || error) });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
|
|
104
148
|
function prepareTunnelDir(home) {
|
|
105
149
|
const dir = tunnelDir(home);
|
|
106
150
|
try {
|
|
@@ -150,7 +194,10 @@ function classifySshFailure(text, remotePort) {
|
|
|
150
194
|
};
|
|
151
195
|
}
|
|
152
196
|
if (/Permission denied \((?:publickey|password|keyboard-interactive)[^)]*\)|No supported authentication methods available/i.test(s)) {
|
|
153
|
-
return {
|
|
197
|
+
return {
|
|
198
|
+
code: 'ssh-auth-failed', detail: 'autenticazione SSH rifiutata',
|
|
199
|
+
hint: 'usa lo stesso Host o alias che funziona con il client SSH di questo dispositivo; chiavi e alias restano locali e il link NON e\' stato consumato',
|
|
200
|
+
};
|
|
154
201
|
}
|
|
155
202
|
if (/REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|authenticity of host .* can'?t be established/i.test(s)) {
|
|
156
203
|
return { code: 'ssh-host-key', detail: 'verifica della host key SSH non completata', hint: 'verifica la host key con il normale client SSH; NexusCrew non la accetta automaticamente' };
|
|
@@ -454,11 +501,6 @@ function startForward(opts) {
|
|
|
454
501
|
return startTunnel({ ...opts, sshBin, name: node.name, args });
|
|
455
502
|
}
|
|
456
503
|
|
|
457
|
-
// Cleanup identifier for pre-0.8.10 standalone rendezvous supervisors. New
|
|
458
|
-
// runtimes never start this second connection; stop/restart can still remove a
|
|
459
|
-
// stale legacy process during migration.
|
|
460
|
-
const REVERSE_NAME = '__rendezvous__';
|
|
461
|
-
|
|
462
504
|
// Versione client OpenSSH, solo diagnostica. Non inferire mai da questa la
|
|
463
505
|
// policy `permitlisten` del server remoto: quella si prova con il vero -R.
|
|
464
506
|
function readSshVersion(spawnSyncImpl) {
|
|
@@ -476,6 +518,7 @@ module.exports = {
|
|
|
476
518
|
buildForwardArgs, backoffDelay,
|
|
477
519
|
tunnelDir, tunnelPidPath, tunnelLogPath, tunnelStatePath, readTunnelState,
|
|
478
520
|
prepareTunnelDir, openTunnelLog,
|
|
521
|
+
tunnelPidNames, reconcileTunnelSupervisors,
|
|
479
522
|
classifySshFailure, readTunnelDiagnostic,
|
|
480
523
|
diagnoseTunnel,
|
|
481
524
|
removeStateIfOwned, supervisorExited,
|
package/lib/runtime/env.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
4
5
|
|
|
5
6
|
const MINIMAL_ENV_KEYS = Object.freeze([
|
|
6
7
|
'PATH', 'HOME', 'SHELL', 'TERM', 'COLORTERM', 'LANG', 'LANGUAGE',
|
|
@@ -37,6 +38,33 @@ function withUtf8Locale(source = process.env, { platform = process.platform } =
|
|
|
37
38
|
return env;
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
// Termux installs tmux with TMUX_TMPDIR=$PREFIX/var/run via a profile.d
|
|
42
|
+
// snippet. Background runtimes and Termux:Boot do not necessarily source that
|
|
43
|
+
// profile, so a first `tmux new-session` would otherwise try the Android/FHS
|
|
44
|
+
// fallback instead of the canonical Termux socket directory. Derive only from
|
|
45
|
+
// an explicit PREFIX or the standard .../files/home layout; never guess paths
|
|
46
|
+
// for Linux/macOS.
|
|
47
|
+
function termuxRuntimePaths(source = process.env, opts = {}) {
|
|
48
|
+
const platform = opts.platform || process.platform;
|
|
49
|
+
const home = String(source.HOME || opts.home || os.homedir());
|
|
50
|
+
const suppliedPrefix = String(source.PREFIX || '');
|
|
51
|
+
const termux = platform === 'android'
|
|
52
|
+
|| suppliedPrefix.includes('com.termux')
|
|
53
|
+
|| (path.basename(home) === 'home' && path.basename(path.dirname(home)) === 'files');
|
|
54
|
+
if (!termux) return null;
|
|
55
|
+
|
|
56
|
+
let prefix = suppliedPrefix;
|
|
57
|
+
if (!prefix && path.basename(home) === 'home' && path.basename(path.dirname(home)) === 'files') {
|
|
58
|
+
prefix = path.join(path.dirname(home), 'usr');
|
|
59
|
+
}
|
|
60
|
+
if (!path.isAbsolute(prefix)) return null;
|
|
61
|
+
return {
|
|
62
|
+
prefix,
|
|
63
|
+
tmpdir: String(source.TMPDIR || path.join(prefix, 'tmp')),
|
|
64
|
+
tmuxTmpdir: String(source.TMUX_TMPDIR || path.join(prefix, 'var', 'run')),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
40
68
|
function minimalRuntimeEnv(source = process.env, opts = {}) {
|
|
41
69
|
const selected = {};
|
|
42
70
|
for (const key of MINIMAL_ENV_KEYS) {
|
|
@@ -44,7 +72,16 @@ function minimalRuntimeEnv(source = process.env, opts = {}) {
|
|
|
44
72
|
}
|
|
45
73
|
if (!selected.PATH) selected.PATH = '/usr/local/bin:/usr/bin:/bin';
|
|
46
74
|
if (!selected.HOME) selected.HOME = opts.home || os.homedir();
|
|
75
|
+
const termux = termuxRuntimePaths(selected, opts);
|
|
76
|
+
if (termux) {
|
|
77
|
+
if (!selected.PREFIX) selected.PREFIX = termux.prefix;
|
|
78
|
+
if (!selected.TMPDIR) selected.TMPDIR = termux.tmpdir;
|
|
79
|
+
if (!selected.TMUX_TMPDIR) selected.TMUX_TMPDIR = termux.tmuxTmpdir;
|
|
80
|
+
}
|
|
47
81
|
return withUtf8Locale(selected, opts);
|
|
48
82
|
}
|
|
49
83
|
|
|
50
|
-
module.exports = {
|
|
84
|
+
module.exports = {
|
|
85
|
+
MINIMAL_ENV_KEYS, UTF8_RE, localeDefaults, withUtf8Locale,
|
|
86
|
+
termuxRuntimePaths, minimalRuntimeEnv,
|
|
87
|
+
};
|
package/lib/server.js
CHANGED
|
@@ -136,6 +136,14 @@ function createServer(opts = {}) {
|
|
|
136
136
|
if (tunnelsStarted) return;
|
|
137
137
|
tunnelsStarted = true;
|
|
138
138
|
const st = nodesStore.loadStore(nodesPath);
|
|
139
|
+
const configured = ((st && st.nodes) || [])
|
|
140
|
+
.filter((node) => node.direction !== 'inbound')
|
|
141
|
+
.map((node) => node.name);
|
|
142
|
+
const reconcile = cfg.reconcileTunnelSupervisorsImpl || nodesTunnel.reconcileTunnelSupervisors;
|
|
143
|
+
const recovered = reconcile({ home: cfg.home || os.homedir(), configuredNames: configured });
|
|
144
|
+
for (const failure of recovered.failed || []) {
|
|
145
|
+
process.stderr.write(`[nexuscrew] orphan tunnel cleanup failed for ${failure.name}: ${failure.reason}\n`);
|
|
146
|
+
}
|
|
139
147
|
// Exactly one connection per configured hub. Legacy `roles.node` /
|
|
140
148
|
// rendezvous data is migration-only and never starts a second hidden SSH
|
|
141
149
|
// process. Publishing this device is the optional -R on its outbound link.
|