@mmmbuto/nexuscrew 0.8.19 → 0.8.21
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 +43 -4
- package/frontend/dist/assets/index-DQbVJLji.css +32 -0
- package/frontend/dist/assets/index-DaFQbMHq.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/init.js +9 -1
- package/lib/decks/routes.js +2 -2
- package/lib/decks/store.js +34 -5
- package/lib/fleet/builtin.js +18 -4
- package/lib/fleet/catalogs/openrouter-kimi-k3.json +44 -0
- package/lib/fleet/managed.js +109 -5
- package/lib/fleet/openrouter-auth-helper.js +21 -0
- package/lib/nodes/commands.js +35 -2
- package/lib/nodes/peering.js +45 -3
- package/lib/nodes/store.js +51 -10
- package/lib/nodes/tunnel-supervisor.js +47 -4
- package/lib/nodes/tunnel.js +46 -1
- package/lib/proxy/federation.js +123 -18
- package/lib/server.js +11 -0
- package/lib/settings/pairing-coordinator.js +3 -3
- package/lib/settings/public-peering-routes.js +39 -11
- package/lib/settings/routes.js +54 -36
- package/lib/tmux/list.js +11 -2
- package/package.json +1 -1
- package/frontend/dist/assets/index-DOHx885g.js +0 -91
- package/frontend/dist/assets/index-kxpt4ezv.css +0 -32
|
@@ -6,7 +6,7 @@ const fs = require('node:fs');
|
|
|
6
6
|
const net = require('node:net');
|
|
7
7
|
const path = require('node:path');
|
|
8
8
|
const { spawn } = require('node:child_process');
|
|
9
|
-
const { backoffDelay } = require('./tunnel.js');
|
|
9
|
+
const { backoffDelay, classifySshFailure } = require('./tunnel.js');
|
|
10
10
|
|
|
11
11
|
const sshBin = process.argv[2];
|
|
12
12
|
const sshArgs = process.argv.slice(3);
|
|
@@ -18,6 +18,9 @@ const stableMs = Number.isFinite(stableMsRaw) && stableMsRaw >= 100 ? Math.min(s
|
|
|
18
18
|
const ownershipGraceRaw = Number(process.env.NEXUSCREW_TUNNEL_OWNERSHIP_GRACE_MS || 2000);
|
|
19
19
|
const ownershipGraceMs = Number.isFinite(ownershipGraceRaw) && ownershipGraceRaw >= 100
|
|
20
20
|
? Math.min(ownershipGraceRaw, 10000) : 2000;
|
|
21
|
+
const reverseFailureMaxRaw = Number(process.env.NEXUSCREW_TUNNEL_REVERSE_FAILURE_MAX || 8);
|
|
22
|
+
const reverseFailureMax = Number.isInteger(reverseFailureMaxRaw) && reverseFailureMaxRaw >= 1
|
|
23
|
+
? Math.min(reverseFailureMaxRaw, 32) : 8;
|
|
21
24
|
if (!sshBin || !statePath || !pidPath || !runId) process.exit(2);
|
|
22
25
|
|
|
23
26
|
let child = null;
|
|
@@ -29,6 +32,7 @@ let forwardProbeTimer = null;
|
|
|
29
32
|
let forwardSocket = null;
|
|
30
33
|
let ownershipWaitTimer = null;
|
|
31
34
|
let ownershipTimer = null;
|
|
35
|
+
let reverseFailures = 0;
|
|
32
36
|
|
|
33
37
|
function localForwardPort(args) {
|
|
34
38
|
for (let i = 0; i < args.length - 1; i += 1) {
|
|
@@ -42,6 +46,18 @@ function localForwardPort(args) {
|
|
|
42
46
|
|
|
43
47
|
const forwardPort = localForwardPort(sshArgs);
|
|
44
48
|
|
|
49
|
+
function reverseForwardPort(args) {
|
|
50
|
+
for (let i = 0; i < args.length - 1; i += 1) {
|
|
51
|
+
if (args[i] !== '-R') continue;
|
|
52
|
+
const match = String(args[i + 1] || '').match(/^127\.0\.0\.1:(\d+):127\.0\.0\.1:\d+$/);
|
|
53
|
+
const port = match ? Number(match[1]) : 0;
|
|
54
|
+
if (Number.isInteger(port) && port >= 1 && port <= 65535) return port;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const reversePort = reverseForwardPort(sshArgs);
|
|
60
|
+
|
|
45
61
|
function logEvent(message) {
|
|
46
62
|
try { process.stderr.write(`[nexuscrew] ${String(message).replace(/[\r\n]+/g, ' ')}\n`); } catch (_) {}
|
|
47
63
|
}
|
|
@@ -121,17 +137,34 @@ function scheduleRetry(detail) {
|
|
|
121
137
|
retryTimer = setTimeout(run, delayMs);
|
|
122
138
|
}
|
|
123
139
|
|
|
140
|
+
function terminalFailure(diagnosis) {
|
|
141
|
+
const safe = diagnosis || { code: 'reverse-forward-failed', detail: 'canale inverso non disponibile' };
|
|
142
|
+
logEvent(`ssh terminal failure code=${safe.code} attempts=${reverseFailures}`);
|
|
143
|
+
writeState('failed', {
|
|
144
|
+
code: safe.code, detail: safe.detail,
|
|
145
|
+
...(safe.hint ? { hint: safe.hint } : {}), terminal: true,
|
|
146
|
+
});
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
149
|
+
|
|
124
150
|
function run() {
|
|
125
151
|
if (stopping) return finish();
|
|
126
152
|
if (!writeState('starting')) return stop();
|
|
127
153
|
logEvent(`ssh attempt=${attempt + 1} starting`);
|
|
154
|
+
let stderrTail = '';
|
|
128
155
|
try {
|
|
129
|
-
child = spawn(sshBin, sshArgs, { stdio: 'inherit' });
|
|
156
|
+
child = spawn(sshBin, sshArgs, { stdio: ['ignore', 'inherit', 'pipe'] });
|
|
130
157
|
} catch (e) {
|
|
131
158
|
child = null;
|
|
132
159
|
return scheduleRetry(String(e && e.message || e));
|
|
133
160
|
}
|
|
134
161
|
|
|
162
|
+
child.stderr?.on('data', (chunk) => {
|
|
163
|
+
const text = String(chunk || '');
|
|
164
|
+
stderrTail = `${stderrTail}${text}`.slice(-8192);
|
|
165
|
+
try { process.stderr.write(chunk); } catch (_) {}
|
|
166
|
+
});
|
|
167
|
+
|
|
135
168
|
let failureHandled = false;
|
|
136
169
|
const handleFailure = (detail) => {
|
|
137
170
|
if (failureHandled) return;
|
|
@@ -139,7 +172,15 @@ function run() {
|
|
|
139
172
|
clearTimeout(upTimer);
|
|
140
173
|
clearForwardProbe();
|
|
141
174
|
child = null;
|
|
142
|
-
|
|
175
|
+
const diagnosis = reversePort ? classifySshFailure(stderrTail, reversePort) : null;
|
|
176
|
+
const reverseFailure = diagnosis && ['reverse-forward-bind', 'reverse-forward-failed'].includes(diagnosis.code);
|
|
177
|
+
if (reverseFailure) {
|
|
178
|
+
reverseFailures += 1;
|
|
179
|
+
if (reverseFailures >= reverseFailureMax) return terminalFailure(diagnosis);
|
|
180
|
+
} else {
|
|
181
|
+
reverseFailures = 0;
|
|
182
|
+
}
|
|
183
|
+
scheduleRetry((diagnosis && diagnosis.detail) || detail);
|
|
143
184
|
};
|
|
144
185
|
child.once('spawn', () => {
|
|
145
186
|
logEvent(`ssh attempt=${attempt + 1} spawned`);
|
|
@@ -156,7 +197,9 @@ function run() {
|
|
|
156
197
|
logEvent(`ssh child error code=${(e && e.code) || 'unknown'}`);
|
|
157
198
|
handleFailure(String(e && e.message || e));
|
|
158
199
|
});
|
|
159
|
-
|
|
200
|
+
// `close` fires after stderr has drained, so classification sees the complete
|
|
201
|
+
// OpenSSH diagnostic instead of racing the child `exit` event.
|
|
202
|
+
child.once('close', (code, signal) => {
|
|
160
203
|
if (stopping) return finish();
|
|
161
204
|
logEvent(`ssh exited code=${code === null ? 'null' : code} signal=${signal || 'none'}`);
|
|
162
205
|
handleFailure(`ssh exited code=${code} signal=${signal || ''}`);
|
package/lib/nodes/tunnel.js
CHANGED
|
@@ -186,6 +186,14 @@ function openTunnelLog(home, name) {
|
|
|
186
186
|
function classifySshFailure(text, remotePort) {
|
|
187
187
|
const s = String(text || '');
|
|
188
188
|
const target = store.isPort(remotePort) ? `127.0.0.1:${remotePort}` : 'la porta NexusCrew richiesta';
|
|
189
|
+
const reverseMatch = s.match(/remote port forwarding failed for listen port\s+(\d+)/i)
|
|
190
|
+
|| s.match(/(?:bind|listen)[^\r\n]*127\.0\.0\.1:(\d+)/i);
|
|
191
|
+
const reverseListenPort = reverseMatch ? Number(reverseMatch[1]) : null;
|
|
192
|
+
const reverseTarget = store.isPort(reverseListenPort)
|
|
193
|
+
? `127.0.0.1:${reverseListenPort}` : 'la porta reverse negoziata';
|
|
194
|
+
const reversePolicy = store.isPort(reverseListenPort)
|
|
195
|
+
? `che la chiave SSH autorizzi permitlisten="${reverseTarget}"`
|
|
196
|
+
: 'che la chiave SSH autorizzi il reverse listen negoziato';
|
|
189
197
|
if (/administratively prohibited|request (?:was )?denied|open failed:.*prohibited|port forwarding.*(?:disabled|denied)/i.test(s)) {
|
|
190
198
|
return {
|
|
191
199
|
code: 'forward-denied',
|
|
@@ -209,7 +217,18 @@ function classifySshFailure(text, remotePort) {
|
|
|
209
217
|
return { code: 'ssh-config', detail: 'configurazione SSH non utilizzabile', hint: 'verifica permessi e opzioni del file ~/.ssh/config' };
|
|
210
218
|
}
|
|
211
219
|
if (/remote port forwarding failed|Could not request remote forwarding/i.test(s)) {
|
|
212
|
-
|
|
220
|
+
if (/Address already in use|cannot listen|bind .*failed/i.test(s)) {
|
|
221
|
+
return {
|
|
222
|
+
code: 'reverse-forward-bind',
|
|
223
|
+
detail: 'il canale inverso non può aprire la porta sul nodo hub perché è già occupata',
|
|
224
|
+
hint: 'verifica un precedente listener SSH sulla stessa porta e l’eventuale doppio supervisor',
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
code: 'reverse-forward-failed',
|
|
229
|
+
detail: 'il canale inverso non è stato aperto dal nodo hub',
|
|
230
|
+
hint: `verifica che ${reverseTarget} non sia occupata e ${reversePolicy}; controlla anche AllowTcpForwarding sul nodo hub`,
|
|
231
|
+
};
|
|
213
232
|
}
|
|
214
233
|
if (/Could not request local forwarding|Address already in use/i.test(s)) {
|
|
215
234
|
return { code: 'local-forward-bind', detail: 'la porta locale scelta per il tunnel non è disponibile', hint: 'ferma il tunnel precedente e riprova' };
|
|
@@ -245,12 +264,27 @@ function tunnelStatePath(home, name) {
|
|
|
245
264
|
function readTunnelState(home, name) {
|
|
246
265
|
const p = tunnelPidPath(home, name);
|
|
247
266
|
const meta = pidf.readPidfile(p);
|
|
267
|
+
const terminalState = () => {
|
|
268
|
+
try {
|
|
269
|
+
const state = JSON.parse(fs.readFileSync(tunnelStatePath(home, name), 'utf8'));
|
|
270
|
+
const owned = meta && state.supervisorPid === meta.pid && (!meta.runId || state.runId === meta.runId);
|
|
271
|
+
if (!owned || state.status !== 'failed' || state.terminal !== true) return null;
|
|
272
|
+
return {
|
|
273
|
+
status: 'down', phase: 'failed', reason: 'failed', pid: meta.pid, since: meta.startTs || null,
|
|
274
|
+
...(typeof state.code === 'string' ? { code: state.code } : {}),
|
|
275
|
+
...(typeof state.detail === 'string' ? { detail: state.detail } : {}),
|
|
276
|
+
...(typeof state.hint === 'string' ? { hint: state.hint } : {}),
|
|
277
|
+
...(Number.isInteger(state.attempt) ? { attempt: state.attempt } : {}),
|
|
278
|
+
};
|
|
279
|
+
} catch (_) { return null; }
|
|
280
|
+
};
|
|
248
281
|
if (meta && pidf.isAlive(meta)) {
|
|
249
282
|
try {
|
|
250
283
|
const state = JSON.parse(fs.readFileSync(tunnelStatePath(home, name), 'utf8'));
|
|
251
284
|
const transport = typeof state.transport === 'string' ? state.transport : undefined;
|
|
252
285
|
const owned = state.supervisorPid === meta.pid && (!meta.runId || state.runId === meta.runId);
|
|
253
286
|
if (!owned) return { status: 'down', pid: meta.pid, since: meta.startTs || null, reason: 'starting' };
|
|
287
|
+
if (state.status === 'failed' && state.terminal === true) return terminalState();
|
|
254
288
|
if (state.status === 'transport-ready' || state.status === 'up') {
|
|
255
289
|
return { status: 'up', phase: 'transport-ready', pid: meta.pid, since: meta.startTs || null,
|
|
256
290
|
...(Number.isInteger(state.attempt) ? { attempt: state.attempt } : {}), ...(transport ? { transport } : {}) };
|
|
@@ -267,6 +301,10 @@ function readTunnelState(home, name) {
|
|
|
267
301
|
return { status: 'down', pid: meta.pid, since: meta.startTs || null, reason: 'starting' };
|
|
268
302
|
}
|
|
269
303
|
}
|
|
304
|
+
if (meta) {
|
|
305
|
+
const terminal = terminalState();
|
|
306
|
+
if (terminal) return terminal;
|
|
307
|
+
}
|
|
270
308
|
return { status: 'down' };
|
|
271
309
|
}
|
|
272
310
|
|
|
@@ -287,6 +325,13 @@ function diagnoseTunnel(home, node, state = null) {
|
|
|
287
325
|
detail: `connessione SSH in retry${Number.isInteger(current.attempt) ? ` (tentativo ${current.attempt + 1})` : ''}`,
|
|
288
326
|
hint: 'usa Test connessione per vedere la causa; verifica target SSH, chiave e porta' };
|
|
289
327
|
}
|
|
328
|
+
if (current.reason === 'failed' || current.phase === 'failed') {
|
|
329
|
+
return {
|
|
330
|
+
stage: 'ssh', code: current.code || 'ssh-terminal-failure', status: 'down', phase: 'failed',
|
|
331
|
+
detail: current.detail || 'connessione SSH fermata dopo errori ripetuti',
|
|
332
|
+
hint: current.hint || 'correggi la causa e riavvia la connessione dalla PWA',
|
|
333
|
+
};
|
|
334
|
+
}
|
|
290
335
|
return { stage: 'ssh', code: 'tunnel-stopped', status: 'down', phase: current.phase || null,
|
|
291
336
|
detail: 'connessione SSH ferma', hint: 'avvia la connessione dalla PWA, Impostazioni > Nodi' };
|
|
292
337
|
}
|
package/lib/proxy/federation.js
CHANGED
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
|
|
14
14
|
const MAX_HOPS = 4;
|
|
15
15
|
const ROUTE_DELIMITER = '_';
|
|
16
|
+
const TOPOLOGY_PEER_TIMEOUT_MS = 1500;
|
|
16
17
|
|
|
17
18
|
function peerFromToken(nodesPath, token) {
|
|
18
19
|
const st = store.loadStore(nodesPath);
|
|
@@ -158,10 +159,16 @@ async function probeHealth({ port, token, expectedInstanceId = null, fetchImpl =
|
|
|
158
159
|
let timer;
|
|
159
160
|
try {
|
|
160
161
|
const ctrl = new AbortController();
|
|
161
|
-
|
|
162
|
-
r = await fetchImpl(`http://127.0.0.1:${port}/federation/health`, {
|
|
162
|
+
const request = fetchImpl(`http://127.0.0.1:${port}/federation/health`, {
|
|
163
163
|
headers: { authorization: `Bearer ${token}` }, signal: ctrl.signal,
|
|
164
164
|
});
|
|
165
|
+
const timeout = new Promise((_, reject) => {
|
|
166
|
+
timer = setTimeout(() => {
|
|
167
|
+
ctrl.abort();
|
|
168
|
+
const e = new Error(`health timeout (${timeoutMs}ms)`); e.name = 'AbortError'; reject(e);
|
|
169
|
+
}, timeoutMs);
|
|
170
|
+
});
|
|
171
|
+
r = await Promise.race([request, timeout]);
|
|
165
172
|
} catch (e) {
|
|
166
173
|
out.transport = 'down';
|
|
167
174
|
out.status = 'down';
|
|
@@ -228,6 +235,71 @@ async function waitForHealthyPeer(opts = {}) {
|
|
|
228
235
|
return last || { status: 'down', detail: 'peer non raggiungibile' };
|
|
229
236
|
}
|
|
230
237
|
|
|
238
|
+
// Aggiorna lo stato Share sul hub attraverso il canale -L autenticato. Non
|
|
239
|
+
// legge mai il body remoto (potrebbe contenere diagnostica non sicura) e non
|
|
240
|
+
// include credenziali negli errori. Usato sia dal toggle interattivo sia dalla
|
|
241
|
+
// riconciliazione al boot.
|
|
242
|
+
async function notifyHubShare({ node, shared, fetchImpl = fetch, timeoutMs = 5000 }) {
|
|
243
|
+
if (!node || !store.isPort(node.localPort) || !store.validToken(node.token)
|
|
244
|
+
|| typeof shared !== 'boolean') throw new Error('parametri riconciliazione Share non validi');
|
|
245
|
+
const ctrl = new AbortController();
|
|
246
|
+
const budget = Number.isInteger(timeoutMs) ? Math.max(100, Math.min(timeoutMs, 30000)) : 5000;
|
|
247
|
+
let timer;
|
|
248
|
+
try {
|
|
249
|
+
const request = fetchImpl(`http://127.0.0.1:${node.localPort}/federation/share`, {
|
|
250
|
+
method: 'POST', signal: ctrl.signal,
|
|
251
|
+
headers: { authorization: `Bearer ${node.token}`, 'content-type': 'application/json' },
|
|
252
|
+
body: JSON.stringify({ shared }),
|
|
253
|
+
});
|
|
254
|
+
const timeout = new Promise((_, reject) => {
|
|
255
|
+
timer = setTimeout(() => {
|
|
256
|
+
ctrl.abort();
|
|
257
|
+
const e = new Error(`hub Share timeout (${budget}ms)`); e.code = 'ETIMEDOUT'; reject(e);
|
|
258
|
+
}, budget);
|
|
259
|
+
});
|
|
260
|
+
const response = await Promise.race([request, timeout]);
|
|
261
|
+
if (!response || !response.ok) throw new Error(`hub Share HTTP ${response && response.status || 'unknown'}`);
|
|
262
|
+
return { shared };
|
|
263
|
+
} finally { clearTimeout(timer); }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Il file locale contiene lo stato desiderato. Dopo un crash in qualunque
|
|
267
|
+
// punto del toggle, il boot ristabilisce il tunnel coerente e ripete l'update
|
|
268
|
+
// del hub: ON torna pubblicato, OFF revoca record stale. Tutto e' bounded.
|
|
269
|
+
async function reconcilePeerShare(opts = {}) {
|
|
270
|
+
const node = opts.node;
|
|
271
|
+
const shared = opts.shared === true;
|
|
272
|
+
if (!node || !store.validToken(node.token) || !store.NODE_ID_RE.test(String(node.nodeId || ''))) {
|
|
273
|
+
throw new Error('peer non associato: riconciliazione Share impossibile');
|
|
274
|
+
}
|
|
275
|
+
const fetchImpl = opts.fetchImpl || fetch;
|
|
276
|
+
const delay = typeof opts.delay === 'function'
|
|
277
|
+
? opts.delay : (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
278
|
+
const health = await waitForHealthyPeer({
|
|
279
|
+
port: node.localPort, token: node.token, expectedInstanceId: node.nodeId,
|
|
280
|
+
fetchImpl,
|
|
281
|
+
attempts: Number.isInteger(opts.healthAttempts) ? opts.healthAttempts : 6,
|
|
282
|
+
delayMs: Number.isInteger(opts.delayMs) ? opts.delayMs : 200,
|
|
283
|
+
delay,
|
|
284
|
+
});
|
|
285
|
+
if (!health || health.status !== 'healthy') {
|
|
286
|
+
throw new Error((health && health.detail) || 'hub non raggiungibile per riconciliare Share');
|
|
287
|
+
}
|
|
288
|
+
const attempts = Number.isInteger(opts.notifyAttempts)
|
|
289
|
+
? Math.max(1, Math.min(opts.notifyAttempts, 6)) : (shared ? 3 : 1);
|
|
290
|
+
let lastError = null;
|
|
291
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
292
|
+
try {
|
|
293
|
+
await notifyHubShare({ node, shared, fetchImpl, timeoutMs: opts.timeoutMs });
|
|
294
|
+
return { shared, health };
|
|
295
|
+
} catch (e) {
|
|
296
|
+
lastError = e;
|
|
297
|
+
if (attempt + 1 < attempts) await delay(Number.isInteger(opts.delayMs) ? opts.delayMs : 200);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
throw lastError || new Error('riconciliazione Share fallita');
|
|
301
|
+
}
|
|
302
|
+
|
|
231
303
|
function controlledVisited(req, ingress, instanceId) {
|
|
232
304
|
const raw = ingress ? String(req.headers['x-nexuscrew-visited'] || '') : '';
|
|
233
305
|
const seen = raw ? raw.split(',').filter(Boolean) : [];
|
|
@@ -241,13 +313,41 @@ function controlledVisited(req, ingress, instanceId) {
|
|
|
241
313
|
return [...seen, instanceId];
|
|
242
314
|
}
|
|
243
315
|
|
|
244
|
-
async function
|
|
316
|
+
async function fetchPeerTopology({ node, ttl, seen, fetchImpl, timeoutMs }) {
|
|
317
|
+
const ctrl = new AbortController();
|
|
318
|
+
const budget = Number.isFinite(timeoutMs)
|
|
319
|
+
? Math.max(1, Math.min(30000, Math.floor(timeoutMs))) : TOPOLOGY_PEER_TIMEOUT_MS;
|
|
320
|
+
let timer;
|
|
321
|
+
const timeout = new Promise((_, reject) => {
|
|
322
|
+
timer = setTimeout(() => {
|
|
323
|
+
ctrl.abort();
|
|
324
|
+
const error = new Error(`topology peer timeout (${budget}ms)`);
|
|
325
|
+
error.code = 'ETIMEDOUT';
|
|
326
|
+
reject(error);
|
|
327
|
+
}, budget);
|
|
328
|
+
});
|
|
329
|
+
const request = (async () => {
|
|
330
|
+
const u = `http://127.0.0.1:${node.localPort}/federation/topology?ttl=${ttl - 1}&visited=${encodeURIComponent([...seen].join(','))}`;
|
|
331
|
+
const response = await fetchImpl(u, {
|
|
332
|
+
headers: { authorization: `Bearer ${node.token}` }, signal: ctrl.signal,
|
|
333
|
+
});
|
|
334
|
+
return { response, body: await response.json() };
|
|
335
|
+
})();
|
|
336
|
+
try { return await Promise.race([request, timeout]); }
|
|
337
|
+
finally { clearTimeout(timer); }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function collectTopologyDetailed({
|
|
341
|
+
nodesPath, ingress = null, ttl = MAX_HOPS, visited = [], fetchImpl = fetch,
|
|
342
|
+
timeoutMs = TOPOLOGY_PEER_TIMEOUT_MS,
|
|
343
|
+
}) {
|
|
245
344
|
const st = store.loadStore(nodesPath);
|
|
246
345
|
if (!st) return { instanceId: null, nodes: [], authoritative: [] };
|
|
247
346
|
const seen = new Set(visited.filter((x) => store.NODE_ID_RE.test(x)));
|
|
248
347
|
seen.add(st.nodeId);
|
|
249
348
|
const out = [];
|
|
250
349
|
const authoritative = [];
|
|
350
|
+
const probes = [];
|
|
251
351
|
for (const n of st.nodes) {
|
|
252
352
|
// The local installation always keeps its outbound hub visible. Inbound
|
|
253
353
|
// clients become part of Hydra only after their explicit Share toggle.
|
|
@@ -256,13 +356,15 @@ async function collectTopologyDetailed({ nodesPath, ingress = null, ttl = MAX_HO
|
|
|
256
356
|
|| (ingress && !canTransit(ingress, n))) continue;
|
|
257
357
|
out.push({ instanceId: n.nodeId, name: n.name, route: [n.name], direct: true });
|
|
258
358
|
if (ttl <= 1 || !n.token) continue;
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
359
|
+
probes.push({ n, pending: fetchPeerTopology({ node: n, ttl, seen, fetchImpl, timeoutMs }) });
|
|
360
|
+
}
|
|
361
|
+
const results = await Promise.all(probes.map(async ({ n, pending }) => {
|
|
362
|
+
try { return { n, ...(await pending) }; } catch (_) { return { n, response: null, body: null }; }
|
|
363
|
+
}));
|
|
364
|
+
for (const { n, response, body } of results) {
|
|
365
|
+
if (!response || !response.ok || body.instanceId !== n.nodeId || !Array.isArray(body.nodes)) continue;
|
|
366
|
+
authoritative.push(n.name);
|
|
367
|
+
for (const child of body.nodes) {
|
|
266
368
|
if (!child || !store.NODE_ID_RE.test(child.instanceId) || child.instanceId === n.nodeId || seen.has(child.instanceId)
|
|
267
369
|
|| !store.NODE_NAME_RE.test(child.name)
|
|
268
370
|
|| !Array.isArray(child.route) || child.route.length < 1 || child.route.length >= ttl
|
|
@@ -271,8 +373,7 @@ async function collectTopologyDetailed({ nodesPath, ingress = null, ttl = MAX_HO
|
|
|
271
373
|
|| child.name !== child.route[child.route.length - 1]
|
|
272
374
|
|| child.route.includes(n.name)) continue;
|
|
273
375
|
out.push({ instanceId: child.instanceId, name: child.name, route: [n.name, ...child.route], direct: false });
|
|
274
|
-
|
|
275
|
-
} catch (_) {}
|
|
376
|
+
}
|
|
276
377
|
}
|
|
277
378
|
const ids = new Set(); const routes = new Set(); const unique = [];
|
|
278
379
|
for (const n of out.sort((a, b) => a.route.length - b.route.length)) {
|
|
@@ -290,8 +391,11 @@ async function collectTopology(opts) {
|
|
|
290
391
|
|
|
291
392
|
// Local roster: live topology plus a credential-free cache of previously seen
|
|
292
393
|
// transitive nodes. Stale entries are never returned by the peer endpoint.
|
|
293
|
-
async function collectLocalTopology({
|
|
294
|
-
|
|
394
|
+
async function collectLocalTopology({
|
|
395
|
+
nodesPath, cachePath, fetchImpl = fetch, now = Math.floor(Date.now() / 1000),
|
|
396
|
+
timeoutMs = TOPOLOGY_PEER_TIMEOUT_MS,
|
|
397
|
+
}) {
|
|
398
|
+
const live = await collectTopologyDetailed({ nodesPath, fetchImpl, timeoutMs });
|
|
295
399
|
const st = store.loadStore(nodesPath);
|
|
296
400
|
const directNames = new Set(((st && st.nodes) || [])
|
|
297
401
|
.filter((n) => n.direction !== 'inbound' || n.shared === true)
|
|
@@ -373,7 +477,7 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
373
477
|
});
|
|
374
478
|
}
|
|
375
479
|
}
|
|
376
|
-
let st = store.
|
|
480
|
+
let st = store.loadStoreStrict(nodesPath);
|
|
377
481
|
const current = store.getNode(st, req.peer.name);
|
|
378
482
|
if (!current) return res.status(404).json({ error: 'peer non trovato' });
|
|
379
483
|
st = store.updateNode(st, current.name, {
|
|
@@ -384,7 +488,7 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
384
488
|
store.atomicWriteStore(nodesPath, st);
|
|
385
489
|
return res.json({ shared: body.shared });
|
|
386
490
|
} catch (e) {
|
|
387
|
-
return res.status(500).json({ error: String(e && e.message || e) });
|
|
491
|
+
return res.status(e.status || 500).json({ error: String(e && e.message || e), ...(e.code ? { code: e.code } : {}) });
|
|
388
492
|
}
|
|
389
493
|
});
|
|
390
494
|
r.use('/route', (req, res) => routeHandler({ nodesPath, localPort, localCredential, ingress: req.peer, readonly })(req, res));
|
|
@@ -434,7 +538,8 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
434
538
|
function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConnection: close\r\n\r\n`); } catch (_) {} }
|
|
435
539
|
|
|
436
540
|
module.exports = {
|
|
437
|
-
MAX_HOPS, ROUTE_DELIMITER,
|
|
541
|
+
MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS,
|
|
542
|
+
peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource,
|
|
438
543
|
collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
|
|
439
|
-
probeHealth, waitForHealthyPeer,
|
|
544
|
+
probeHealth, waitForHealthyPeer, notifyHubShare, reconcilePeerShare,
|
|
440
545
|
};
|
package/lib/server.js
CHANGED
|
@@ -156,6 +156,17 @@ function createServer(opts = {}) {
|
|
|
156
156
|
});
|
|
157
157
|
if (!tr.started && tr.reason !== 'already running') {
|
|
158
158
|
process.stderr.write(`[nexuscrew] peer ${node.name} autostart failed: ${tr.reason || 'unknown'}\n`);
|
|
159
|
+
} else if (node.token && node.acceptToken && node.nodeId) {
|
|
160
|
+
// `shared` in nodes.json e' lo stato desiderato. Una riconciliazione
|
|
161
|
+
// asincrona chiude la finestra di crash tra write locale e ACK hub
|
|
162
|
+
// senza ritardare il listen del server.
|
|
163
|
+
const reconcileShare = cfg.reconcilePeerShareImpl || federation.reconcilePeerShare;
|
|
164
|
+
Promise.resolve(reconcileShare({
|
|
165
|
+
node, shared: node.shared === true, fetchImpl: healthFetch,
|
|
166
|
+
healthAttempts: 3, notifyAttempts: node.shared === true ? 3 : 1, delayMs: 200,
|
|
167
|
+
})).catch((e) => {
|
|
168
|
+
process.stderr.write(`[nexuscrew] peer ${node.name} Share reconcile pending: ${String(e && e.message || e).replace(/Bearer\s+\S+/gi, 'Bearer ***')}\n`);
|
|
169
|
+
});
|
|
159
170
|
}
|
|
160
171
|
}
|
|
161
172
|
}
|
|
@@ -117,7 +117,7 @@ function createPairHandler(deps) {
|
|
|
117
117
|
|
|
118
118
|
try {
|
|
119
119
|
// --- conflict: il nome non deve gia' esistere --------------------------
|
|
120
|
-
let st = nodesStore.
|
|
120
|
+
let st = nodesStore.loadStoreStrict(nodesPath);
|
|
121
121
|
if (st.nodes.some((n) => n.name === b.name)) {
|
|
122
122
|
return fail(409, 'conflict', 'name-exists', `nodo "${b.name}" gia' presente`, {
|
|
123
123
|
retryable: true, hint: 'scegli un altro nome nelle opzioni avanzate e riprova',
|
|
@@ -253,7 +253,7 @@ function createPairHandler(deps) {
|
|
|
253
253
|
// --- tunnel-final: connessione privata, solo -L -------------------------
|
|
254
254
|
// reversePort resta negoziata per un futuro Share opt-in, ma il builder
|
|
255
255
|
// non emette -R finche' shared non diventa true.
|
|
256
|
-
st = nodesStore.
|
|
256
|
+
st = nodesStore.loadStoreStrict(nodesPath);
|
|
257
257
|
st = nodesStore.updateNode(st, b.name, {
|
|
258
258
|
token: joined.credential, nodeId: joined.instanceId, reversePort: joined.reversePort,
|
|
259
259
|
shared: false,
|
|
@@ -317,7 +317,7 @@ function createPairHandler(deps) {
|
|
|
317
317
|
send(res, 200, { paired: true, name: b.name, instanceId: joined.instanceId, transport: 'auto', health: { status: health.status } });
|
|
318
318
|
} catch (e) {
|
|
319
319
|
await rollback();
|
|
320
|
-
fail(502, 'internal', 'unexpected', String((e && e.message) || e), {});
|
|
320
|
+
fail(e.status || 502, 'internal', e.code || 'unexpected', String((e && e.message) || e), {});
|
|
321
321
|
}
|
|
322
322
|
};
|
|
323
323
|
}
|
|
@@ -46,7 +46,7 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
46
46
|
if (!st || !nodesStore.NODE_ID_RE.test(st.nodeId)) return res.status(503).json({ error: 'identita nodo non disponibile' });
|
|
47
47
|
return res.json({ ok: true, instanceId: st.nodeId, proof });
|
|
48
48
|
});
|
|
49
|
-
r.post('/join', (req, res) => {
|
|
49
|
+
r.post('/join', async (req, res) => {
|
|
50
50
|
const key = `join:${String(req.socket && req.socket.remoteAddress || 'local')}`;
|
|
51
51
|
const now = Date.now();
|
|
52
52
|
const recent = (attempts.get(key) || []).filter((x) => now - x < 60_000);
|
|
@@ -65,21 +65,42 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
65
65
|
if (b.label !== undefined && !nodesStore.validLabel(b.label)) {
|
|
66
66
|
return res.status(400).json({ error: 'label non valida' });
|
|
67
67
|
}
|
|
68
|
-
|
|
68
|
+
// Validate the one-time capability before conflict checks without burning
|
|
69
|
+
// it: a duplicate/stale peer must not destroy an otherwise reusable invite.
|
|
70
|
+
if (!peering.hasInvite({ invitesPath, invite: b.invite, now })) return res.status(410).json({ error: 'invito scaduto o gia usato' });
|
|
71
|
+
let credential = null;
|
|
69
72
|
try {
|
|
70
|
-
const st = nodesStore.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
73
|
+
const st = nodesStore.loadStoreStrict(nodesPath);
|
|
74
|
+
const pending = peering.readInvites(pendingPath, now);
|
|
75
|
+
if (st.nodeId === b.instanceId || st.nodes.some((n) => n.nodeId === b.instanceId)
|
|
76
|
+
|| pending.some((row) => row.instanceId === b.instanceId)) return res.status(409).json({ error: 'peer duplicato' });
|
|
77
|
+
const reservedNames = new Set(pending.map((row) => row.name).filter(validPeerName));
|
|
78
|
+
if (nodesStore.getNode(st, b.name) || reservedNames.has(b.name)) {
|
|
79
|
+
return res.status(409).json({
|
|
80
|
+
error: `nome peer gia' in uso: ${b.name}`,
|
|
81
|
+
code: 'peer-name-conflict',
|
|
82
|
+
hint: 'rimuovi il record stale oppure scegli un nome univoco e riprova con lo stesso invito',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
const name = b.name;
|
|
86
|
+
const reversePort = await peering.allocateAvailableReversePort(st.nodes, pending, {
|
|
87
|
+
...(deps.createServerImpl ? { createServerImpl: deps.createServerImpl } : {}),
|
|
88
|
+
});
|
|
89
|
+
credential = peering.createPending({ pendingPath, data: {
|
|
76
90
|
name, remotePort: b.port, reversePort, instanceId: b.instanceId, acceptToken: b.acceptToken,
|
|
77
91
|
shared: false,
|
|
78
92
|
label: nodesStore.sanitizeLabel(b.label, name),
|
|
79
93
|
...(peerRoles ? { roles: { ...peerRoles, node: false }, rolesKnown: true } : { rolesKnown: false }),
|
|
80
94
|
} });
|
|
95
|
+
if (!peering.consumeInvite({ invitesPath, invite: b.invite, now })) {
|
|
96
|
+
peering.consumePending({ pendingPath, credential, now });
|
|
97
|
+
return res.status(410).json({ error: 'invito scaduto o gia usato' });
|
|
98
|
+
}
|
|
81
99
|
res.json({ paired: true, instanceId: st.nodeId, reversePort, credential, roles: readRoles(configPath) });
|
|
82
|
-
} catch (e) {
|
|
100
|
+
} catch (e) {
|
|
101
|
+
if (credential) try { peering.consumePending({ pendingPath, credential, now }); } catch (_) {}
|
|
102
|
+
res.status(e.status || 500).json({ error: String(e.message || e), ...(e.code ? { code: e.code } : {}) });
|
|
103
|
+
}
|
|
83
104
|
});
|
|
84
105
|
r.post('/confirm', (req, res) => {
|
|
85
106
|
const b = req.body || {};
|
|
@@ -91,8 +112,15 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
91
112
|
return res.status(410).json({ error: 'pairing pending scaduto o gia usato' });
|
|
92
113
|
}
|
|
93
114
|
try {
|
|
94
|
-
let st = nodesStore.
|
|
115
|
+
let st = nodesStore.loadStoreStrict(nodesPath);
|
|
95
116
|
if (st.nodeId === pending.instanceId || st.nodes.some((n) => n.nodeId === pending.instanceId)) return res.status(409).json({ error: 'peer duplicato' });
|
|
117
|
+
if (st.nodes.some((n) => n.name === pending.name || n.localPort === pending.reversePort)) {
|
|
118
|
+
return res.status(409).json({
|
|
119
|
+
error: 'allocazione pairing non piu disponibile',
|
|
120
|
+
code: 'pairing-allocation-conflict',
|
|
121
|
+
hint: 'ripeti il pairing: verra negoziata una nuova porta reverse',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
96
124
|
st = nodesStore.addNode(st, {
|
|
97
125
|
name: pending.name, remotePort: pending.remotePort, localPort: pending.reversePort,
|
|
98
126
|
direction: 'inbound', transport: 'inbound', autostart: true,
|
|
@@ -104,7 +132,7 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
104
132
|
});
|
|
105
133
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
106
134
|
res.json({ confirmed: true });
|
|
107
|
-
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
135
|
+
} catch (e) { res.status(e.status || 500).json({ error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
|
|
108
136
|
});
|
|
109
137
|
r.post('/cancel', (req, res) => {
|
|
110
138
|
const b = req.body || {};
|