@mmmbuto/nexuscrew 0.8.19 → 0.8.20
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 +31 -3
- package/frontend/dist/assets/index-CQnOyaXz.js +93 -0
- package/frontend/dist/assets/index-DQbVJLji.css +32 -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 +38 -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 +31 -29
- 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
|
@@ -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 || {};
|
package/lib/settings/routes.js
CHANGED
|
@@ -47,7 +47,7 @@ const nodesStore = require('../nodes/store.js');
|
|
|
47
47
|
const nodesCmds = require('../nodes/commands.js');
|
|
48
48
|
const nodesTunnel = require('../nodes/tunnel.js');
|
|
49
49
|
const peering = require('../nodes/peering.js');
|
|
50
|
-
const { waitForHealthyPeer } = require('../proxy/federation.js');
|
|
50
|
+
const { waitForHealthyPeer, notifyHubShare } = require('../proxy/federation.js');
|
|
51
51
|
const { rotateToken } = require('../auth/token.js');
|
|
52
52
|
const { generateService, installService, installPath: svcInstallPath } = require('../cli/service.js');
|
|
53
53
|
const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js');
|
|
@@ -215,7 +215,7 @@ function settingsRoutes(deps = {}) {
|
|
|
215
215
|
};
|
|
216
216
|
if (updateStatus) out.update = updateStatus;
|
|
217
217
|
send(res, 200, out);
|
|
218
|
-
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
218
|
+
} catch (e) { send(res, e.status || 500, { error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
|
|
219
219
|
});
|
|
220
220
|
|
|
221
221
|
// --- POST /config — scrittura atomica, subset whitelisted ------------------
|
|
@@ -333,7 +333,7 @@ function settingsRoutes(deps = {}) {
|
|
|
333
333
|
try {
|
|
334
334
|
const b = req.body || {};
|
|
335
335
|
const label = nodesStore.sanitizeLabel(b.label, defaultDeviceName());
|
|
336
|
-
const st = nodesStore.
|
|
336
|
+
const st = nodesStore.loadStoreStrict(nodesPath);
|
|
337
337
|
const extra = {};
|
|
338
338
|
if (typeof b.ssh === 'string' && b.ssh.trim()) {
|
|
339
339
|
const ssh = nodesStore.parseSshTarget(b.ssh.trim());
|
|
@@ -359,7 +359,7 @@ function settingsRoutes(deps = {}) {
|
|
|
359
359
|
send(res, 200, peering.createInvite({
|
|
360
360
|
invitesPath, instanceId: st.nodeId, port: remotePort, linkPort: runtimePort(), label, ...extra,
|
|
361
361
|
}));
|
|
362
|
-
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
362
|
+
} catch (e) { send(res, e.status || 500, { error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
|
|
363
363
|
});
|
|
364
364
|
|
|
365
365
|
// --- POST /nodes/pair — hydra join in stadi espliciti ---------------------
|
|
@@ -413,7 +413,7 @@ function settingsRoutes(deps = {}) {
|
|
|
413
413
|
// impossibile (validata sopra), ma non facciamo mai fallire l'add per lei.
|
|
414
414
|
if (b.label !== undefined) {
|
|
415
415
|
try {
|
|
416
|
-
let st = nodesStore.
|
|
416
|
+
let st = nodesStore.loadStoreStrict(nodesPath);
|
|
417
417
|
st = nodesStore.updateNode(st, out.name, { label: nodesStore.sanitizeLabel(b.label, out.name) });
|
|
418
418
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
419
419
|
} catch (_) { /* best-effort: il nodo e' gia' creato */ }
|
|
@@ -435,7 +435,11 @@ function settingsRoutes(deps = {}) {
|
|
|
435
435
|
const status = /duplicato|self-reference/.test(msg) ? 409 : 400;
|
|
436
436
|
return send(res, status, { error: msg });
|
|
437
437
|
}
|
|
438
|
-
//
|
|
438
|
+
// Store mancante/invalido e' indisponibilita operativa, mai un invito a
|
|
439
|
+
// ricrearlo implicitamente. Gli altri errori restano 500.
|
|
440
|
+
if (out.reason === 'store invalido') {
|
|
441
|
+
return send(res, out.status || 503, { error: msg, ...(out.errorCode ? { code: out.errorCode } : {}) });
|
|
442
|
+
}
|
|
439
443
|
return send(res, 500, { error: msg });
|
|
440
444
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
441
445
|
});
|
|
@@ -522,25 +526,15 @@ function settingsRoutes(deps = {}) {
|
|
|
522
526
|
if (body.shared && !nodesStore.isPort(node.reversePort)) {
|
|
523
527
|
return send(res, 409, { error: 'canale share non negoziato: ripeti il pairing' });
|
|
524
528
|
}
|
|
525
|
-
|
|
526
|
-
|
|
529
|
+
const wasShared = node.shared === true;
|
|
530
|
+
let persistedDesired = wasShared;
|
|
527
531
|
const fetchImpl = seams.fetchImpl || fetch;
|
|
528
|
-
const notifyHub =
|
|
529
|
-
const ctrl = new AbortController();
|
|
530
|
-
const timer = setTimeout(() => ctrl.abort(), 5000);
|
|
531
|
-
try {
|
|
532
|
-
const response = await fetchImpl(`http://127.0.0.1:${node.localPort}/federation/share`, {
|
|
533
|
-
method: 'POST', signal: ctrl.signal,
|
|
534
|
-
headers: { authorization: `Bearer ${node.token}`, 'content-type': 'application/json' },
|
|
535
|
-
body: JSON.stringify({ shared }),
|
|
536
|
-
});
|
|
537
|
-
if (!response.ok) throw new Error(`hub HTTP ${response.status}`);
|
|
538
|
-
} finally { clearTimeout(timer); }
|
|
539
|
-
};
|
|
532
|
+
const notifyHub = (shared) => notifyHubShare({ node, shared, fetchImpl, timeoutMs: 5000 });
|
|
540
533
|
const applyLocal = async (shared) => {
|
|
541
|
-
st = nodesStore.
|
|
534
|
+
st = nodesStore.loadStoreStrict(nodesPath);
|
|
542
535
|
st = nodesStore.updateNode(st, name, { shared });
|
|
543
536
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
537
|
+
persistedDesired = shared;
|
|
544
538
|
node = nodesStore.getNode(st, name);
|
|
545
539
|
nodesTunnel.stopTunnel({ home, name });
|
|
546
540
|
const started = nodesTunnel.startForward({
|
|
@@ -563,23 +557,31 @@ function settingsRoutes(deps = {}) {
|
|
|
563
557
|
};
|
|
564
558
|
|
|
565
559
|
try {
|
|
560
|
+
if (node.shared === body.shared) {
|
|
561
|
+
await notifyHub(body.shared);
|
|
562
|
+
return send(res, 200, { name, shared: body.shared, unchanged: true, reconciled: true });
|
|
563
|
+
}
|
|
566
564
|
if (body.shared) {
|
|
567
565
|
await applyLocal(true);
|
|
568
566
|
await notifyHub(true);
|
|
569
567
|
} else {
|
|
570
|
-
//
|
|
571
|
-
|
|
568
|
+
// Persist OFF first. If the process dies before the hub ACK, the next
|
|
569
|
+
// boot starts only -L and reconciles the stored false state.
|
|
572
570
|
await applyLocal(false);
|
|
571
|
+
await notifyHub(false);
|
|
573
572
|
}
|
|
574
573
|
return send(res, 200, { name, shared: body.shared });
|
|
575
574
|
} catch (e) {
|
|
576
575
|
// Share-on is transactional: a failed hub acknowledgement returns to the
|
|
577
576
|
// safe private -L-only state. Never include remote response bodies/tokens.
|
|
578
|
-
if (body.shared) {
|
|
577
|
+
if (body.shared && !wasShared) {
|
|
579
578
|
try { await applyLocal(false); } catch (_) { /* best-effort safe rollback */ }
|
|
580
579
|
}
|
|
580
|
+
const offPersisted = body.shared === false && persistedDesired === false;
|
|
581
581
|
return send(res, 502, {
|
|
582
|
-
error: body.shared ? 'Share non attivato'
|
|
582
|
+
error: body.shared ? 'Share non attivato'
|
|
583
|
+
: offPersisted ? 'Share disattivato localmente; hub non riconciliato' : 'Share non disattivato',
|
|
584
|
+
...(offPersisted ? { shared: false, reconcilePending: true } : {}),
|
|
583
585
|
detail: String(e && e.message || e).replace(/Bearer\s+\S+/gi, 'Bearer ***'),
|
|
584
586
|
});
|
|
585
587
|
}
|
|
@@ -593,11 +595,11 @@ function settingsRoutes(deps = {}) {
|
|
|
593
595
|
if (!validName(name) || !['network', 'relay-only', 'selected'].includes(visibility)) {
|
|
594
596
|
return send(res, 400, { error: 'visibility non valida' });
|
|
595
597
|
}
|
|
596
|
-
let st = nodesStore.
|
|
598
|
+
let st = nodesStore.loadStoreStrict(nodesPath);
|
|
597
599
|
st = nodesStore.updateNode(st, name, { visibility, selected: visibility === 'selected' ? selected : [] });
|
|
598
600
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
599
601
|
send(res, 200, { saved: true, name, visibility });
|
|
600
|
-
} catch (e) { send(res, 400, { error: String(e.message || e) }); }
|
|
602
|
+
} catch (e) { send(res, e.status || 400, { error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
|
|
601
603
|
});
|
|
602
604
|
// Rinomina la label umana di un nodo SENZA toccare il name (route/URL stabili).
|
|
603
605
|
r.patch('/nodes/:name/label', mutGate, (req, res) => {
|
|
@@ -606,12 +608,12 @@ function settingsRoutes(deps = {}) {
|
|
|
606
608
|
const label = req.body && req.body.label;
|
|
607
609
|
if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
|
|
608
610
|
if (!nodesStore.validLabel(label)) return send(res, 400, { error: 'label non valida (max 64 char, niente a capo)' });
|
|
609
|
-
let st = nodesStore.
|
|
611
|
+
let st = nodesStore.loadStoreStrict(nodesPath);
|
|
610
612
|
if (!nodesStore.getNode(st, name)) return send(res, 404, { error: `nodo sconosciuto "${name}"` });
|
|
611
613
|
st = nodesStore.updateNode(st, name, { label: nodesStore.sanitizeLabel(label, name) });
|
|
612
614
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
613
615
|
send(res, 200, { saved: true, name, label: nodesStore.nodeLabel(nodesStore.getNode(st, name)) });
|
|
614
|
-
} catch (e) { send(res, 400, { error: String(e.message || e) }); }
|
|
616
|
+
} catch (e) { send(res, e.status || 400, { error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
|
|
615
617
|
});
|
|
616
618
|
|
|
617
619
|
// Legacy compatibility endpoint: the old node-role/rendezvous flow opened a
|
package/lib/tmux/list.js
CHANGED
|
@@ -52,6 +52,15 @@ function parseSessions(raw) {
|
|
|
52
52
|
.filter((session) => session.windows > 0);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// tmux uses different expected "no server" errors across platforms. Linux
|
|
56
|
+
// commonly prints "no server running" while macOS reports the missing socket
|
|
57
|
+
// path. Both mean an empty inventory, not a broken NexusCrew node.
|
|
58
|
+
function isNoTmuxServerError(raw) {
|
|
59
|
+
const message = String(raw || '');
|
|
60
|
+
return /no server running/i.test(message)
|
|
61
|
+
|| /error connecting to .*(?:No such file or directory|Connection refused)/i.test(message);
|
|
62
|
+
}
|
|
63
|
+
|
|
55
64
|
function setSessionVisibility(tmuxBin = 'tmux', name, technical = false) {
|
|
56
65
|
return new Promise((resolve, reject) => {
|
|
57
66
|
if (typeof name !== 'string' || !/^[\w.@%:+-]{1,128}$/.test(name) || name.startsWith('-')) {
|
|
@@ -73,7 +82,7 @@ function listSessions(tmuxBin = 'tmux') {
|
|
|
73
82
|
return new Promise((resolve, reject) => {
|
|
74
83
|
execFile(tmuxBin, ['list-sessions', '-F', FMT], (err, stdout, stderr) => {
|
|
75
84
|
if (err) {
|
|
76
|
-
if (
|
|
85
|
+
if (isNoTmuxServerError(stderr || err.message)) return resolve([]);
|
|
77
86
|
return reject(new Error(`tmux list-sessions failed: ${stderr || err.message}`));
|
|
78
87
|
}
|
|
79
88
|
resolve(parseSessions(stdout));
|
|
@@ -95,5 +104,5 @@ function attachedClients(tmuxBin = 'tmux', session) {
|
|
|
95
104
|
}
|
|
96
105
|
|
|
97
106
|
module.exports = {
|
|
98
|
-
parsePaneTitle, parseSessions, listSessions, attachedClients, setSessionVisibility, FMT,
|
|
107
|
+
parsePaneTitle, parseSessions, isNoTmuxServerError, listSessions, attachedClients, setSessionVisibility, FMT,
|
|
99
108
|
};
|