@mmmbuto/nexuscrew 0.8.9 → 0.8.10
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 +39 -18
- package/frontend/dist/assets/index-CbUkgtAz.js +91 -0
- package/frontend/dist/assets/index-ChGJawuv.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/auth/token.js +1 -1
- package/lib/cli/commands.js +236 -141
- package/lib/cli/doctor.js +17 -14
- package/lib/cli/init.js +2 -2
- package/lib/cli/pidfile.js +3 -2
- package/lib/decks/store.js +5 -2
- package/lib/fleet/builtin.js +12 -1
- package/lib/mcp/server.js +161 -0
- package/lib/nodes/commands.js +95 -123
- package/lib/nodes/health.js +22 -14
- package/lib/nodes/peering.js +72 -10
- package/lib/nodes/store.js +21 -18
- package/lib/nodes/tunnel-supervisor.js +29 -9
- package/lib/nodes/tunnel.js +217 -72
- package/lib/proxy/federation.js +68 -6
- package/lib/server.js +36 -31
- package/lib/settings/routes.js +202 -95
- package/package.json +2 -2
- package/skills/nexuscrew-agent/SKILL.md +6 -2
- package/frontend/dist/assets/index-9Drd8g0k.css +0 -32
- package/frontend/dist/assets/index-DTmT7yhV.js +0 -91
package/lib/nodes/peering.js
CHANGED
|
@@ -7,6 +7,8 @@ const store = require('./store.js');
|
|
|
7
7
|
|
|
8
8
|
const INVITE_TTL_MS = 10 * 60 * 1000;
|
|
9
9
|
const REVERSE_PORT_BASE = 44001;
|
|
10
|
+
const CAPABILITY_ID_RE = /^[a-f0-9]{64}$/;
|
|
11
|
+
const CHALLENGE_RE = /^[A-Za-z0-9_-]{16,128}$/;
|
|
10
12
|
|
|
11
13
|
function defaultInvitesPath(home) { return path.join(home, '.nexuscrew', 'invites.json'); }
|
|
12
14
|
function defaultPendingPath(home) { return path.join(home, '.nexuscrew', 'pairing-pending.json'); }
|
|
@@ -162,21 +164,81 @@ function consumePending({ pendingPath, credential, now = Date.now() }) {
|
|
|
162
164
|
return found;
|
|
163
165
|
}
|
|
164
166
|
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
167
|
+
// Prova pubblica ma capability-bound usata durante il pairing. Il client NON
|
|
168
|
+
// invia mai l'invito/credential: invia SHA256(SHA256(capability)) + challenge;
|
|
169
|
+
// il peer cerca il record attivo e firma il challenge con SHA256(capability).
|
|
170
|
+
// Un processo HTTP estraneo sulla stessa porta non puo' quindi diventare un
|
|
171
|
+
// falso positivo e non riceve materiale sufficiente per consumare l'invito.
|
|
172
|
+
function capabilityIdentity({ invitesPath, pendingPath, capabilityId, challenge, now = Date.now() } = {}) {
|
|
173
|
+
if (!CAPABILITY_ID_RE.test(String(capabilityId || '')) || !CHALLENGE_RE.test(String(challenge || ''))) return null;
|
|
174
|
+
const rows = [
|
|
175
|
+
...readInvites(invitesPath, now),
|
|
176
|
+
...readInvites(pendingPath, now),
|
|
177
|
+
];
|
|
178
|
+
const row = rows.find((x) => {
|
|
179
|
+
if (!x || !CAPABILITY_ID_RE.test(String(x.hash || ''))) return false;
|
|
180
|
+
const id = crypto.createHash('sha256').update(Buffer.from(x.hash, 'hex')).digest('hex');
|
|
181
|
+
return safeEqual(id, capabilityId);
|
|
182
|
+
});
|
|
183
|
+
if (!row) return null;
|
|
184
|
+
return crypto.createHmac('sha256', Buffer.from(row.hash, 'hex')).update(challenge).digest('base64url');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function capabilityProbeMaterial(capability, randomBytes = crypto.randomBytes) {
|
|
188
|
+
if (!store.validToken(capability)) return null;
|
|
189
|
+
const key = crypto.createHash('sha256').update(capability).digest();
|
|
190
|
+
return {
|
|
191
|
+
key,
|
|
192
|
+
capabilityId: crypto.createHash('sha256').update(key).digest('hex'),
|
|
193
|
+
challenge: randomBytes(24).toString('base64url'),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Probe di trasporto del tunnel -L PRIMA di consumare l'invito e dopo il
|
|
198
|
+
// restart negoziato. Oltre alla reachability verifica prova capability e
|
|
199
|
+
// instanceId atteso. Bounded, con sleep/random iniettabili per test.
|
|
200
|
+
async function probeTransportReady({
|
|
201
|
+
port, capability, expectedInstanceId, fetchImpl = fetch, attempts = 6,
|
|
202
|
+
timeoutMs = 1500, sleep, randomBytes,
|
|
203
|
+
} = {}) {
|
|
171
204
|
const wait = typeof sleep === 'function' ? sleep : (ms) => new Promise((r) => setTimeout(r, ms));
|
|
205
|
+
const material = capabilityProbeMaterial(capability, randomBytes);
|
|
206
|
+
if (!store.isPort(port) || !material || !store.NODE_ID_RE.test(String(expectedInstanceId || ''))) {
|
|
207
|
+
return { ready: false, attempts: 0, code: 'identity-probe-invalid', lastError: 'parametri identity probe non validi' };
|
|
208
|
+
}
|
|
172
209
|
let lastError = '';
|
|
210
|
+
let code = 'transport-not-ready';
|
|
173
211
|
for (let i = 0; i < attempts; i += 1) {
|
|
174
212
|
let timer;
|
|
175
213
|
try {
|
|
176
214
|
const ctrl = new AbortController();
|
|
177
215
|
timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
178
|
-
await fetchImpl(`http://127.0.0.1:${port}/
|
|
179
|
-
|
|
216
|
+
const response = await fetchImpl(`http://127.0.0.1:${port}/pair/identity`, {
|
|
217
|
+
method: 'POST',
|
|
218
|
+
headers: { 'content-type': 'application/json' },
|
|
219
|
+
body: JSON.stringify({ capabilityId: material.capabilityId, challenge: material.challenge }),
|
|
220
|
+
signal: ctrl.signal,
|
|
221
|
+
});
|
|
222
|
+
if (!response || response.status !== 200) {
|
|
223
|
+
code = 'identity-proof-rejected';
|
|
224
|
+
throw new Error(`identity probe HTTP ${(response && response.status) || '?'}`);
|
|
225
|
+
}
|
|
226
|
+
const body = await response.json().catch(() => null);
|
|
227
|
+
if (!body || body.ok !== true || !store.NODE_ID_RE.test(String(body.instanceId || ''))
|
|
228
|
+
|| typeof body.proof !== 'string') {
|
|
229
|
+
code = 'identity-proof-invalid';
|
|
230
|
+
throw new Error('identity probe payload non valido');
|
|
231
|
+
}
|
|
232
|
+
if (body.instanceId !== expectedInstanceId) {
|
|
233
|
+
code = 'peer-identity-mismatch';
|
|
234
|
+
throw new Error('instanceId del peer non coincide con il link');
|
|
235
|
+
}
|
|
236
|
+
const expected = crypto.createHmac('sha256', material.key).update(material.challenge).digest('base64url');
|
|
237
|
+
if (!safeEqual(expected, body.proof)) {
|
|
238
|
+
code = 'identity-proof-invalid';
|
|
239
|
+
throw new Error('prova crittografica del peer non valida');
|
|
240
|
+
}
|
|
241
|
+
return { ready: true, attempts: i + 1, instanceId: body.instanceId };
|
|
180
242
|
} catch (e) {
|
|
181
243
|
lastError = String((e && e.message) || e);
|
|
182
244
|
} finally {
|
|
@@ -184,12 +246,12 @@ async function probeTransportReady({ port, fetchImpl = fetch, attempts = 6, time
|
|
|
184
246
|
}
|
|
185
247
|
if (i < attempts - 1) await wait(250 * (i + 1));
|
|
186
248
|
}
|
|
187
|
-
return { ready: false, attempts, lastError };
|
|
249
|
+
return { ready: false, attempts, code, lastError };
|
|
188
250
|
}
|
|
189
251
|
|
|
190
252
|
module.exports = {
|
|
191
253
|
INVITE_TTL_MS, REVERSE_PORT_BASE, defaultInvitesPath, defaultPendingPath, safeEqual,
|
|
192
254
|
readInvites, writeInvites, encodePairing, decodePairing, parsePairingUrl,
|
|
193
255
|
createInvite, consumeInvite, allocateReversePort, createPending, consumePending,
|
|
194
|
-
probeTransportReady,
|
|
256
|
+
capabilityIdentity, capabilityProbeMaterial, probeTransportReady,
|
|
195
257
|
};
|
package/lib/nodes/store.js
CHANGED
|
@@ -104,7 +104,7 @@ const LABEL_MAX = 64;
|
|
|
104
104
|
const NODE_KEYS = new Set([
|
|
105
105
|
'name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'identityFile',
|
|
106
106
|
'roles', 'rolesKnown', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
|
|
107
|
-
'direction', 'reversePort', 'label',
|
|
107
|
+
'direction', 'reversePort', 'shared', 'label',
|
|
108
108
|
]);
|
|
109
109
|
function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
110
110
|
if (!n || typeof n !== 'object' || Array.isArray(n)) return null;
|
|
@@ -148,12 +148,18 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
148
148
|
direction,
|
|
149
149
|
transport: n.transport || (direction === 'inbound' ? 'inbound' : (schemaVersion === LEGACY_SCHEMA_VERSION ? 'ssh' : 'auto')),
|
|
150
150
|
autostart: n.autostart === undefined ? schemaVersion !== LEGACY_SCHEMA_VERSION : n.autostart,
|
|
151
|
+
// A paired device is private by default. `shared` only controls whether the
|
|
152
|
+
// hub may advertise/route this peer and whether the outbound SSH session
|
|
153
|
+
// requests the optional reverse (-R) channel. Old stores therefore migrate
|
|
154
|
+
// safely to private without a schema bump.
|
|
155
|
+
shared: n.shared === undefined ? false : n.shared,
|
|
151
156
|
visibility: n.visibility || 'network',
|
|
152
157
|
};
|
|
153
158
|
if (!['auto', 'ssh', 'autossh', 'inbound'].includes(out.transport)) return null;
|
|
154
159
|
if (direction === 'inbound' && out.transport !== 'inbound') return null;
|
|
155
160
|
if (direction === 'outbound' && out.transport === 'inbound') return null;
|
|
156
161
|
if (typeof out.autostart !== 'boolean') return null;
|
|
162
|
+
if (typeof out.shared !== 'boolean') return null;
|
|
157
163
|
if (!['network', 'relay-only', 'selected'].includes(out.visibility)) return null;
|
|
158
164
|
if (identityFile) out.identityFile = identityFile;
|
|
159
165
|
// Keep the old public field while reading v1 so old callers/tests and a
|
|
@@ -188,7 +194,9 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
188
194
|
return out;
|
|
189
195
|
}
|
|
190
196
|
|
|
191
|
-
// rendezvous
|
|
197
|
+
// Legacy rendezvous record: read-only migration data from pre-0.8.10. It is
|
|
198
|
+
// parsed so an existing store remains loadable, but no new runtime path writes
|
|
199
|
+
// or starts it.
|
|
192
200
|
const RDV_KEYS = new Set(['ssh', 'publishedPort', 'localPort', 'keyPath']);
|
|
193
201
|
function parseRendezvous(r) {
|
|
194
202
|
if (!r || typeof r !== 'object' || Array.isArray(r)) return null;
|
|
@@ -362,18 +370,6 @@ function updateNode(store, name, patch) {
|
|
|
362
370
|
return { ...store, schemaVersion: SCHEMA_VERSION, nodes };
|
|
363
371
|
}
|
|
364
372
|
|
|
365
|
-
function setRendezvous(store, rdv) {
|
|
366
|
-
const parsed = parseRendezvous(rdv);
|
|
367
|
-
if (!parsed) throw new Error('rendezvous non valido: controlla ssh/publishedPort/localPort/keyPath');
|
|
368
|
-
return { ...store, schemaVersion: SCHEMA_VERSION, rendezvous: parsed };
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function clearRendezvous(store) {
|
|
372
|
-
const out = { ...store, schemaVersion: SCHEMA_VERSION };
|
|
373
|
-
delete out.rendezvous;
|
|
374
|
-
return out;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
373
|
// --- Redazione (view sicura per status/list: MAI il token) ------------------
|
|
378
374
|
|
|
379
375
|
function redactNode(n) {
|
|
@@ -388,6 +384,7 @@ function redactNode(n) {
|
|
|
388
384
|
rolesKnown: n.rolesKnown === true,
|
|
389
385
|
transport: n.transport || 'ssh',
|
|
390
386
|
autostart: !!n.autostart,
|
|
387
|
+
shared: n.shared === true,
|
|
391
388
|
visibility: n.visibility || 'network',
|
|
392
389
|
hasToken: !!n.token, // presenza, non il valore
|
|
393
390
|
paired: !!(n.token && n.acceptToken),
|
|
@@ -405,11 +402,17 @@ function redactStore(store) {
|
|
|
405
402
|
nodeId: store.nodeId,
|
|
406
403
|
nodes: store.nodes.map(redactNode),
|
|
407
404
|
};
|
|
408
|
-
// rendezvous non contiene token: sicuro da esporre integralmente.
|
|
409
|
-
if (store.rendezvous) out.rendezvous = { ...store.rendezvous };
|
|
410
405
|
return out;
|
|
411
406
|
}
|
|
412
407
|
|
|
408
|
+
// A peer with both scoped credentials has completed pairing. Its advertised
|
|
409
|
+
// HTTP port is part of the established SSH/federation contract; silently
|
|
410
|
+
// moving that port would strand the peer even though the local PWA still works.
|
|
411
|
+
function hasPairedPeers(store) {
|
|
412
|
+
return !!(store && Array.isArray(store.nodes)
|
|
413
|
+
&& store.nodes.some((node) => validToken(node.token) && validToken(node.acceptToken)));
|
|
414
|
+
}
|
|
415
|
+
|
|
413
416
|
// --- Migrazione esplicita da config.json (guarded, no-op se assente) ---------
|
|
414
417
|
// Se config.json contiene un array `nodes` legacy (vecchio formato pre-B0),
|
|
415
418
|
// lo importa in nodes.json. Guarded: no-op se config.json non ha `nodes`, o se
|
|
@@ -497,9 +500,9 @@ module.exports = {
|
|
|
497
500
|
// I/O
|
|
498
501
|
defaultNodesPath, loadStore, atomicWriteStore, loadOrInitStore, emptyStore, newNodeId,
|
|
499
502
|
// mutazioni
|
|
500
|
-
getNode, addNode, removeNode, setNodeToken, updateNode,
|
|
503
|
+
getNode, addNode, removeNode, setNodeToken, updateNode,
|
|
501
504
|
// redazione
|
|
502
|
-
redactNode, redactStore,
|
|
505
|
+
redactNode, redactStore, hasPairedPeers,
|
|
503
506
|
// migrazione
|
|
504
507
|
migrateLegacyNodes,
|
|
505
508
|
// label / slug
|
|
@@ -10,7 +10,11 @@ const { backoffDelay } = require('./tunnel.js');
|
|
|
10
10
|
const sshBin = process.argv[2];
|
|
11
11
|
const sshArgs = process.argv.slice(3);
|
|
12
12
|
const statePath = process.env.NEXUSCREW_TUNNEL_STATE;
|
|
13
|
-
|
|
13
|
+
const pidPath = process.env.NEXUSCREW_TUNNEL_PIDFILE;
|
|
14
|
+
const runId = process.env.NEXUSCREW_TUNNEL_RUN_ID;
|
|
15
|
+
const stableMsRaw = Number(process.env.NEXUSCREW_TUNNEL_STABLE_MS || 3000);
|
|
16
|
+
const stableMs = Number.isFinite(stableMsRaw) && stableMsRaw >= 100 ? Math.min(stableMsRaw, 30000) : 3000;
|
|
17
|
+
if (!sshBin || !statePath || !pidPath || !runId) process.exit(2);
|
|
14
18
|
|
|
15
19
|
let child = null;
|
|
16
20
|
let stopping = false;
|
|
@@ -18,16 +22,26 @@ let attempt = 0;
|
|
|
18
22
|
let retryTimer = null;
|
|
19
23
|
let upTimer = null;
|
|
20
24
|
|
|
25
|
+
function ownsGeneration() {
|
|
26
|
+
try {
|
|
27
|
+
const meta = JSON.parse(fs.readFileSync(pidPath, 'utf8'));
|
|
28
|
+
return meta && meta.pid === process.pid && meta.runId === runId;
|
|
29
|
+
} catch (_) { return false; }
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
function writeState(status, extra = {}) {
|
|
22
|
-
|
|
23
|
-
const
|
|
33
|
+
if (!ownsGeneration()) return false;
|
|
34
|
+
const tmp = `${statePath}.tmp.${process.pid}.${runId}`;
|
|
35
|
+
const data = { status, runId, transport: path.basename(sshBin), supervisorPid: process.pid, attempt, updatedAt: Date.now(), ...extra };
|
|
24
36
|
try {
|
|
25
37
|
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
26
38
|
fs.writeFileSync(tmp, `${JSON.stringify(data)}\n`, { mode: 0o600 });
|
|
27
39
|
fs.chmodSync(tmp, 0o600);
|
|
28
40
|
fs.renameSync(tmp, statePath);
|
|
41
|
+
return true;
|
|
29
42
|
} catch (_) {
|
|
30
43
|
try { fs.unlinkSync(tmp); } catch (_e) {}
|
|
44
|
+
return false;
|
|
31
45
|
}
|
|
32
46
|
}
|
|
33
47
|
|
|
@@ -58,14 +72,15 @@ function run() {
|
|
|
58
72
|
scheduleRetry(detail);
|
|
59
73
|
};
|
|
60
74
|
child.once('spawn', () => {
|
|
61
|
-
// ExitOnForwardFailure makes bind/auth failures exit.
|
|
62
|
-
//
|
|
75
|
+
// ExitOnForwardFailure makes bind/auth failures exit. Do not reset backoff
|
|
76
|
+
// or advertise readiness on the spawn event: only a stable transport window
|
|
77
|
+
// qualifies as transport-ready; HTTP federation health is checked above us.
|
|
63
78
|
upTimer = setTimeout(() => {
|
|
64
79
|
if (!stopping && child && child.exitCode == null) {
|
|
65
80
|
attempt = 0;
|
|
66
|
-
writeState('
|
|
81
|
+
writeState('transport-ready', { sshPid: child.pid, stableMs });
|
|
67
82
|
}
|
|
68
|
-
},
|
|
83
|
+
}, stableMs);
|
|
69
84
|
});
|
|
70
85
|
child.once('error', (e) => {
|
|
71
86
|
handleFailure(String(e && e.message || e));
|
|
@@ -79,8 +94,13 @@ function run() {
|
|
|
79
94
|
function finish() {
|
|
80
95
|
clearTimeout(retryTimer);
|
|
81
96
|
clearTimeout(upTimer);
|
|
82
|
-
|
|
83
|
-
|
|
97
|
+
if (ownsGeneration()) {
|
|
98
|
+
writeState('down');
|
|
99
|
+
try {
|
|
100
|
+
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
|
101
|
+
if (state.runId === runId && state.supervisorPid === process.pid) fs.unlinkSync(statePath);
|
|
102
|
+
} catch (_) {}
|
|
103
|
+
}
|
|
84
104
|
process.exit(0);
|
|
85
105
|
}
|
|
86
106
|
|