@mmmbuto/nexuscrew 0.9.29 → 0.9.31
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/CHANGELOG.md +20 -0
- package/docs/LIVE_PROMPT.md +1 -1
- package/docs/NOTIFICATIONS.md +37 -0
- package/frontend/dist/assets/index-DaAL7P-m.js +167 -0
- package/frontend/dist/assets/{index-CywSos2e.css → index-DqCfZFGi.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +91 -10
- package/frontend/dist/version.json +1 -1
- package/lib/audio/groups.js +1 -1
- package/lib/auth/token.js +4 -4
- package/lib/cli/commands.js +59 -12
- package/lib/cli/doctor.js +36 -7
- package/lib/cli/fleet-service.js +7 -7
- package/lib/cli/init.js +27 -27
- package/lib/cli/pidfile.js +1 -1
- package/lib/cli/service.js +6 -6
- package/lib/cli/stable-alias.js +4 -4
- package/lib/cli/url.js +1 -1
- package/lib/config.js +36 -6
- package/lib/decks/routes.js +11 -1
- package/lib/files/routes.js +1 -1
- package/lib/fleet/boot.js +2 -2
- package/lib/fleet/builtin.js +28 -19
- package/lib/fleet/causes.js +1 -1
- package/lib/fleet/cell-exec.js +23 -23
- package/lib/fleet/cell-hold.js +6 -6
- package/lib/fleet/cell-lease-server.js +106 -106
- package/lib/fleet/cell-lease.js +21 -21
- package/lib/fleet/definitions.js +21 -13
- package/lib/fleet/identity-authority.js +1 -1
- package/lib/fleet/launch-broker.js +13 -13
- package/lib/fleet/launch.js +15 -15
- package/lib/fleet/lease-client.js +23 -23
- package/lib/fleet/lease-routes.js +3 -3
- package/lib/fleet/lease-verifier.js +22 -22
- package/lib/fleet/managed.js +46 -36
- package/lib/fleet/prompt-delivery.js +13 -13
- package/lib/fleet/routes.js +15 -10
- package/lib/fleet/runtime.js +26 -26
- package/lib/live-host/bridge.js +36 -36
- package/lib/live-host/routes.js +2 -2
- package/lib/live-host/store.js +4 -4
- package/lib/mcp/server.js +14 -14
- package/lib/mcp/tools.js +5 -5
- package/lib/nodes/access-presets.js +258 -0
- package/lib/nodes/commands.js +198 -4
- package/lib/nodes/store.js +152 -9
- package/lib/nodes/tunnel.js +7 -7
- package/lib/notify/ask-answer-service.js +184 -0
- package/lib/notify/ask-receipts.js +197 -0
- package/lib/notify/ask-relay.js +152 -0
- package/lib/notify/asks.js +28 -3
- package/lib/notify/event-feed-acl.js +95 -0
- package/lib/notify/event-feed-asks-routes.js +144 -0
- package/lib/notify/event-feed-client.js +434 -0
- package/lib/notify/event-feed-history.js +115 -0
- package/lib/notify/event-feed-producers.js +175 -0
- package/lib/notify/event-feed-routes.js +254 -0
- package/lib/notify/event-feed.js +328 -0
- package/lib/notify/events.js +1 -1
- package/lib/notify/notifier.js +3 -3
- package/lib/notify/push-relay.js +240 -0
- package/lib/notify/push.js +8 -8
- package/lib/notify/routes.js +62 -23
- package/lib/proxy/federation.js +74 -3
- package/lib/proxy/node-proxy.js +13 -13
- package/lib/proxy/panel-auth.js +4 -4
- package/lib/proxy/panel-proxy.js +2 -2
- package/lib/proxy/resource-acl.js +145 -0
- package/lib/server.js +372 -39
- package/lib/settings/pairing-coordinator.js +1 -1
- package/lib/settings/routes.js +45 -32
- package/lib/update/manager.js +1 -1
- package/lib/update/runner.js +1 -1
- package/lib/voice/transcribe.js +1 -1
- package/lib/ws/bridge.js +3 -3
- package/package.json +1 -1
- package/frontend/dist/assets/index-B_THfiL5.js +0 -167
package/lib/notify/routes.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
//
|
|
2
|
+
// Routes of the MCP bridge: notify, web-push, asks. Mounted inside the
|
|
3
3
|
// router /api gia' dietro requireToken (server.js) — qui restano solo:
|
|
4
4
|
// - READONLY come FLOOR (da revisione): sono gated 403 TUTTI i mutanti — answer
|
|
5
5
|
// (scrittura PTY via paste), push subscribe/unsubscribe (push.json) e la
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
// Il paste della risposta riusa ESATTAMENTE pasteToSession (bracketed literal,
|
|
19
19
|
// niente Invio, control char rifiutati): qui si sanifica il testo PRIMA.
|
|
20
20
|
const express = require('express');
|
|
21
|
+
const { createAskAnswerService } = require('./ask-answer-service.js');
|
|
21
22
|
const { isValidSession } = require('../files/store.js');
|
|
22
23
|
const { normalizeNotificationLang } = require('./language.js');
|
|
23
24
|
const { HOP_HEADER } = require('../proxy/hop-proof.js');
|
|
@@ -110,7 +111,16 @@ function notifyRoutes({
|
|
|
110
111
|
// apre per omissione.
|
|
111
112
|
localNodeId = () => null, originResolver = null, acl = null, dispatcher = null,
|
|
112
113
|
federatedRate = null,
|
|
114
|
+
answerService = null, receipts = null,
|
|
113
115
|
}) {
|
|
116
|
+
// The shared answer cycle is built from the local deps when the caller does
|
|
117
|
+
// not inject one: the local route and the federated surface must never drift
|
|
118
|
+
// into two different implementations.
|
|
119
|
+
const askService = answerService || createAskAnswerService({
|
|
120
|
+
asks, paste,
|
|
121
|
+
labelPrefix: replyLabel(cfg),
|
|
122
|
+
onClosure: (kind, info) => { try { notifier.emitRaw({ type: kind, id: info.askId }); } catch (_) {} },
|
|
123
|
+
});
|
|
114
124
|
const r = express.Router();
|
|
115
125
|
const json = express.json({ limit: '16kb' });
|
|
116
126
|
const bindingGuard = createIdentityBindingGuard({
|
|
@@ -196,7 +206,9 @@ function notifyRoutes({
|
|
|
196
206
|
return res.status(429).json({ status: 'refused', reason: `rate-${quota.bucket}` });
|
|
197
207
|
}
|
|
198
208
|
}
|
|
199
|
-
|
|
209
|
+
// Delivery ONLY: a federated notify is delivered to this node's UI
|
|
210
|
+
// and never re-enters the event publisher (no loop, no re-export).
|
|
211
|
+
const delivered = await notifier.deliverOnly({
|
|
200
212
|
title: b.title.trim(), body: b.body, urgency: b.urgency, lang,
|
|
201
213
|
// Il mittente NON e' `b.session`: quel campo lo dichiara il chiamante.
|
|
202
214
|
// Qui vale solo cio' che la catena ha provato, piu' la cella che il
|
|
@@ -204,7 +216,7 @@ function notifyRoutes({
|
|
|
204
216
|
originNode: resolved.origin.node,
|
|
205
217
|
originCell: resolved.origin.cell,
|
|
206
218
|
});
|
|
207
|
-
//
|
|
219
|
+
// : lo status e' DERIVATO dai conteggi, non dichiarato a parte.
|
|
208
220
|
// `emit` e' best-effort — push fallito → 0, `ui` conta i write SSE
|
|
209
221
|
// riusciti — e il dispatcher propaga SOLO l'etichetta (i conteggi
|
|
210
222
|
// muoiono in forward(), rilievo da revisione): per la cella mittente e'
|
|
@@ -304,6 +316,7 @@ function notifyRoutes({
|
|
|
304
316
|
urgency: 'high',
|
|
305
317
|
session: ask.session,
|
|
306
318
|
lang: 'it',
|
|
319
|
+
askId: ask.id,
|
|
307
320
|
url: `/#ask=${ask.id}`,
|
|
308
321
|
});
|
|
309
322
|
res.status(201).json({ id: ask.id });
|
|
@@ -326,9 +339,12 @@ function notifyRoutes({
|
|
|
326
339
|
const ask = asks.get(id);
|
|
327
340
|
const binding = await guardBinding(req, ask && ask.session);
|
|
328
341
|
if (binding instanceof Error) return bindingRejected(res, binding);
|
|
329
|
-
|
|
342
|
+
// Same service as the federated path: one authoritative state, so the
|
|
343
|
+
// pending/unknown constraint cannot be bypassed from here.
|
|
344
|
+
const out = askService.dismiss(id);
|
|
330
345
|
if (!out.ok) {
|
|
331
346
|
if (out.reason === 'unknown') return res.status(404).json({ error: 'ask inesistente' });
|
|
347
|
+
if (out.reason === 'delivery-unknown-block') return res.status(409).json({ error: out.error, reason: out.reason });
|
|
332
348
|
if (out.reason === 'answering') return res.status(409).json({ error: 'risposta in corso: non si scarta un ask in answering' });
|
|
333
349
|
return res.status(500).json({ error: 'dismiss non riuscito' });
|
|
334
350
|
}
|
|
@@ -348,7 +364,7 @@ function notifyRoutes({
|
|
|
348
364
|
// Validazione del testo PRIMA del claim: nessun claim da rilasciare su 400.
|
|
349
365
|
const raw = req.body && req.body.text;
|
|
350
366
|
if (typeof raw !== 'string') return res.status(400).json({ error: 'text deve essere una stringa' });
|
|
351
|
-
//
|
|
367
|
+
// Oltre il tetto si RIFIUTA, non si tronca. I due tetti vicini in casa
|
|
352
368
|
// (title/body della notifica, nc_send_cell) rifiutano da sempre; qui invece
|
|
353
369
|
// la textarea non ha limite, la route troncava a MAX_ANSWER e rispondeva
|
|
354
370
|
// {answered:true}: l'operatore incollava una config e la cella riceveva la
|
|
@@ -358,7 +374,7 @@ function notifyRoutes({
|
|
|
358
374
|
// La lunghezza misurata ENTRA nel messaggio: la route la conosce, e senza
|
|
359
375
|
// di essa chi ha incollato 12000 caratteri sa che c'e' un tetto ma non di
|
|
360
376
|
// quanto deve tagliare. Dirti che hai sbagliato senza dirti di quanto e'
|
|
361
|
-
//
|
|
377
|
+
// La stessa meta' di difetto che corregge altrove.
|
|
362
378
|
if (sanitized.length > MAX_ANSWER) {
|
|
363
379
|
return res.status(400).json({ error: `text troppo lungo: ${sanitized.length} caratteri, il massimo e' ${MAX_ANSWER}` });
|
|
364
380
|
}
|
|
@@ -367,25 +383,48 @@ function notifyRoutes({
|
|
|
367
383
|
const ask = asks.get(id);
|
|
368
384
|
const binding = await guardBinding(req, ask && ask.session);
|
|
369
385
|
if (binding instanceof Error) return bindingRejected(res, binding);
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
386
|
+
// ONE answer cycle for local and federated: the service owns the
|
|
387
|
+
// claim -> paste -> commit flow AND the delivery-unknown lock. The
|
|
388
|
+
// closure event is emitted by the service, not here.
|
|
389
|
+
const out = await askService.answerLocal({ askId: id, text });
|
|
390
|
+
if (!out.ok) return res.status(out.code || 500).json({ error: out.error });
|
|
391
|
+
res.json({ answered: true, id });
|
|
392
|
+
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// Explicit reconciliation: local and authenticated only, NEVER
|
|
396
|
+
// federata (la route non e' in allowlist). Decide l'esito dei tentativi
|
|
397
|
+
// delivery-unknown e sblocca l'ask; CAS facoltativa sulla revisione.
|
|
398
|
+
r.post('/asks/:id/reconcile', mutGate, json, (req, res) => {
|
|
399
|
+
try {
|
|
400
|
+
const id = String(req.params.id || '');
|
|
401
|
+
const ask = asks.get(id);
|
|
402
|
+
if (!ask) return res.status(404).json({ error: 'ask inesistente' });
|
|
403
|
+
const body = req.body || {};
|
|
404
|
+
const decision = body.decision;
|
|
405
|
+
if (decision !== 'mark-delivered' && decision !== 'allow-new-attempt') {
|
|
406
|
+
return res.status(400).json({ error: 'decision deve essere mark-delivered|allow-new-attempt' });
|
|
376
407
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
return res.status(
|
|
408
|
+
const out = askService.reconcile({ askId: id, decision, expectedRevision: body.expectedRevision });
|
|
409
|
+
if (!out.ok) {
|
|
410
|
+
if (out.reason === 'revision-required' || out.reason === 'bad-decision') {
|
|
411
|
+
return res.status(400).json({ error: out.error, reason: out.reason });
|
|
412
|
+
}
|
|
413
|
+
if (out.reason === 'revision-conflict') return res.status(409).json({ error: out.error, reason: out.reason });
|
|
414
|
+
if (out.reason === 'unknown') return res.status(404).json({ error: out.error, reason: out.reason });
|
|
415
|
+
if (out.reason === 'persist-failed') {
|
|
416
|
+
return res.status(500).json({ error: 'reconcile non persistito: ask ancora bloccato', reason: out.reason });
|
|
417
|
+
}
|
|
418
|
+
// Il servizio dichiara il proprio codice: il rifiuto di una
|
|
419
|
+
// riconciliazione su un paste VIVO (answering) o senza alcun esito
|
|
420
|
+
// incerto da riconciliare (nothing-to-reconcile, generation-changed)
|
|
421
|
+
// e' una decisione dell'operatore rifiutata, non un errore interno.
|
|
422
|
+
if (out.code === 409) {
|
|
423
|
+
return res.status(409).json({ error: out.error, reason: out.reason });
|
|
424
|
+
}
|
|
425
|
+
return res.status(500).json({ error: out.error || 'reconcile non riuscito', reason: out.reason });
|
|
384
426
|
}
|
|
385
|
-
|
|
386
|
-
// Le altre UI aperte tolgono la card/badge senza aspettare il poll.
|
|
387
|
-
notifier.emitRaw({ type: 'ask-answered', id });
|
|
388
|
-
res.json({ answered: true, id });
|
|
427
|
+
res.json({ reconciled: true, askId: id, decision, attempts: out.changed, revision: out.revision });
|
|
389
428
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
390
429
|
});
|
|
391
430
|
|
package/lib/proxy/federation.js
CHANGED
|
@@ -5,6 +5,7 @@ const net = require('node:net');
|
|
|
5
5
|
const express = require('express');
|
|
6
6
|
const store = require('../nodes/store.js');
|
|
7
7
|
const topologyCache = require('../nodes/topology-cache.js');
|
|
8
|
+
const { allowedByClass, classifyResource } = require('./resource-acl.js');
|
|
8
9
|
const reverseRotation = require('../nodes/reverse-rotation.js');
|
|
9
10
|
const { bearerFrom } = require('../auth/middleware.js');
|
|
10
11
|
const { safeEqual } = require('../nodes/peering.js');
|
|
@@ -176,8 +177,8 @@ function knownResource(resource) {
|
|
|
176
177
|
|| resource === '/ws'
|
|
177
178
|
// Un avviso all'operatore attraversa la federazione con la stessa spina
|
|
178
179
|
// dorsale dell'audio: origine provata dalla catena `visited`, ACL del
|
|
179
|
-
// target, budget
|
|
180
|
-
//
|
|
180
|
+
// target, dedicated budget. It is NOT the authority-model queue: that one
|
|
181
|
+
// defers an execution, this one shows text and executes nothing.
|
|
181
182
|
// non esegue nulla. `/audio/speak` esce da un altoparlante in una stanza
|
|
182
183
|
// fisica ed e' gia' federato: una notifica e' meno invasiva di cosi'.
|
|
183
184
|
|| resource === '/notify'
|
|
@@ -192,6 +193,15 @@ function knownResource(resource) {
|
|
|
192
193
|
// significava poter creare a distanza un engine che non si puo' rendere
|
|
193
194
|
// avviabile a distanza — un'asimmetria che rompeva la modifica remota a
|
|
194
195
|
// meta' strada, senza proteggere nulla.
|
|
196
|
+
// Event feed: a closed GET surface inside the authenticated API.
|
|
197
|
+
// The handler demands a proven last hop on top of the class gate — the
|
|
198
|
+
// allowlist only admits the resources exist at all.
|
|
199
|
+
|| resource === '/event-feed'
|
|
200
|
+
|| resource === '/event-feed/snapshot'
|
|
201
|
+
// Federated ask write-back: answer, dismiss, attempt status.
|
|
202
|
+
|| /^\/event-feed\/asks\/[a-f0-9]{8}(?:\/answer|\/requests\/[0-9a-f-]{16,64})?$/.test(resource)
|
|
203
|
+
|| /^\/event-feed\/[A-Za-z0-9._-]{1,32}$/.test(resource)
|
|
204
|
+
|| /^\/event-feed\/node\/[a-f0-9]{32}$/.test(resource)
|
|
195
205
|
|| isPanelResource(resource)
|
|
196
206
|
|| /^\/fleet\/(status|schema|definitions|credentials\/status|credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource);
|
|
197
207
|
}
|
|
@@ -229,6 +239,16 @@ function liveHostAllowedFor(ingress) {
|
|
|
229
239
|
return ingress.liveHostAccess === true;
|
|
230
240
|
}
|
|
231
241
|
|
|
242
|
+
// One decision, used by BOTH federated entry points: the HTTP relay and the
|
|
243
|
+
// WebSocket upgrade. A resource whose class is unknown, or whose grants the
|
|
244
|
+
// owner has not conceded, is refused before anything is dispatched.
|
|
245
|
+
function classGateCheck(resource, method, ingress) {
|
|
246
|
+
const cls = classifyResource(resource, method);
|
|
247
|
+
if (!cls) return { ok: false, status: 404, reason: 'resource-not-classified' };
|
|
248
|
+
if (!allowedByClass(cls, ingress)) return { ok: false, status: 403, reason: `grant-required:${cls}` };
|
|
249
|
+
return { ok: true, className: cls };
|
|
250
|
+
}
|
|
251
|
+
|
|
232
252
|
function allowedResource(resource, method = 'GET') {
|
|
233
253
|
if (isPanelResource(resource)) return method === 'GET' || method === 'POST';
|
|
234
254
|
if (resource === '/live-host') return method === 'GET';
|
|
@@ -273,6 +293,13 @@ function allowedResource(resource, method = 'GET') {
|
|
|
273
293
|
// Notify: solo POST. `/events`, `/asks` e `/push/*` restano NON federati —
|
|
274
294
|
// SSE non puo' autenticarsi col Bearer e un ask ha un canale di ritorno che
|
|
275
295
|
// e' un paste nel tmux locale, quindi non attraverserebbe comunque.
|
|
296
|
+
// Event feed: GET only, no mutation, no upgrade.
|
|
297
|
+
if (resource === '/event-feed' || resource === '/event-feed/snapshot') return method === 'GET';
|
|
298
|
+
if (/^\/event-feed\/[A-Za-z0-9._-]{1,32}$/.test(resource)) return method === 'GET';
|
|
299
|
+
if (/^\/event-feed\/node\/[a-f0-9]{32}$/.test(resource)) return method === 'GET';
|
|
300
|
+
if (/^\/event-feed\/asks\/[a-f0-9]{8}\/answer$/.test(resource)) return method === 'POST';
|
|
301
|
+
if (/^\/event-feed\/asks\/[a-f0-9]{8}$/.test(resource)) return method === 'DELETE';
|
|
302
|
+
if (/^\/event-feed\/asks\/[a-f0-9]{8}\/requests\/[0-9a-f-]{16,64}$/.test(resource)) return method === 'GET';
|
|
276
303
|
if (resource === '/notify') return method === 'POST';
|
|
277
304
|
// Audio: only capability read, speak and stop are exposed through Hydra.
|
|
278
305
|
// audio.consent is a LOCAL mutation and MUST stay unreachable federated.
|
|
@@ -293,7 +320,27 @@ function readonlyBlocksFederated(resource, method) {
|
|
|
293
320
|
return resource !== '/audio/speak/status' && resource !== '/audio/stop';
|
|
294
321
|
}
|
|
295
322
|
|
|
323
|
+
const EVENT_FEED_CURSOR_RE = /^[0-9]{1,12}:[0-9]{1,12}$/;
|
|
324
|
+
|
|
296
325
|
function allowedQuery(resource, method, rawUrl) {
|
|
326
|
+
// The event feed has a CLOSED query surface: only `after=<epoch>:<seq>` on
|
|
327
|
+
// the SSE stream, nothing at all on the snapshot. A token in a query is
|
|
328
|
+
// refused on these paths whatever it looks like.
|
|
329
|
+
if (resource === '/event-feed' || resource === '/event-feed/snapshot'
|
|
330
|
+
|| /^\/event-feed\/[A-Za-z0-9._-]{1,32}$/.test(resource)
|
|
331
|
+
|| /^\/event-feed\/node\/[a-f0-9]{32}$/.test(resource)) {
|
|
332
|
+
const index = String(rawUrl || '').indexOf('?');
|
|
333
|
+
if (index < 0) return true;
|
|
334
|
+
const params = new URLSearchParams(String(rawUrl).slice(index + 1));
|
|
335
|
+
const keys = [...params.keys()];
|
|
336
|
+
if (keys.some((k) => !/^[A-Za-z0-9_]+$/.test(k))) return false;
|
|
337
|
+
if (params.has('token') || params.has('access_token')) return false;
|
|
338
|
+
if (resource !== '/event-feed') return keys.length === 0;
|
|
339
|
+
if (params.getAll('after').length > 1) return false;
|
|
340
|
+
if (keys.length === 0) return true;
|
|
341
|
+
if (keys.length !== 1 || !params.has('after')) return false;
|
|
342
|
+
return EVENT_FEED_CURSOR_RE.test(params.get('after') || '');
|
|
343
|
+
}
|
|
297
344
|
if (!resource.startsWith('/diagnostics/')) return true;
|
|
298
345
|
const index = String(rawUrl || '').indexOf('?');
|
|
299
346
|
if (index < 0) return true;
|
|
@@ -428,6 +475,20 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
|
|
|
428
475
|
if (parsed && isLiveHostResource(parsed.resource) && !liveHostAllowedFor(ingress)) {
|
|
429
476
|
return res.status(403).json({ error: 'cella ospite live non concessa da questo nodo', reason: 'live-host-not-granted' });
|
|
430
477
|
}
|
|
478
|
+
// Class gate, before the dispatch: every federated resource belongs to a
|
|
479
|
+
// class, and the class decides which grants the owner must have conceded.
|
|
480
|
+
// A resource that is allowlisted but not classified is DENIED — a new route
|
|
481
|
+
// must not reach a limited peer by accident — and the coverage test fails
|
|
482
|
+
// when the allowlist grows without the classification.
|
|
483
|
+
if (parsed && allowedResource(parsed.resource, req.method)) {
|
|
484
|
+
const gate = classGateCheck(parsed.resource, req.method, ingress);
|
|
485
|
+
if (!gate.ok) {
|
|
486
|
+
return res.status(gate.status).json({
|
|
487
|
+
error: gate.status === 404 ? 'not found' : 'risorsa non concessa a questo nodo',
|
|
488
|
+
reason: gate.reason,
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
431
492
|
if (!parsed || !allowedResource(parsed.resource, req.method)
|
|
432
493
|
|| !allowedQuery(parsed.resource, req.method, req.url)) return res.status(404).json({ error: 'not found' });
|
|
433
494
|
if (readonly() && readonlyBlocksFederated(parsed.resource, req.method)) return res.status(403).json({ error: 'READONLY: federated mutation blocked' });
|
|
@@ -797,7 +858,7 @@ async function reconcilePeerShare(opts = {}) {
|
|
|
797
858
|
throw lastError || new Error('riconciliazione Share fallita');
|
|
798
859
|
}
|
|
799
860
|
|
|
800
|
-
//
|
|
861
|
+
// Share-off reconciliation runner at boot.
|
|
801
862
|
// no-overlap (un solo runner attivo per nome), al massimo tre round per processo,
|
|
802
863
|
// backoff nominato 0/1000/5000 ms (iniettabile). Re-read dello stato desiderato
|
|
803
864
|
// (nodes.json) prima di ogni round: abort se non e' piu' shared:false (es. il peer
|
|
@@ -1044,7 +1105,11 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
1044
1105
|
r.get('/health', (_req, res) => {
|
|
1045
1106
|
const st = store.loadStore(nodesPath);
|
|
1046
1107
|
const advertisedRoles = typeof roles === 'function' ? roles() : null;
|
|
1108
|
+
// eventFeedV1: the peer checks this capability BEFORE subscribing to the
|
|
1109
|
+
// event feed. An older node reports no capability and the client marks the
|
|
1110
|
+
// feed unsupported instead of retrying forever.
|
|
1047
1111
|
res.json({ ok: true, instanceId: (st && st.nodeId) || null, version, readonly: !!readonly(),
|
|
1112
|
+
eventFeedV1: true,
|
|
1048
1113
|
...(advertisedRoles ? { roles: advertisedRoles } : {}) });
|
|
1049
1114
|
});
|
|
1050
1115
|
// Il client puo' verificare SOLO la reverse port che il pairing gli ha
|
|
@@ -1321,6 +1386,12 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1321
1386
|
// frame arrivano da qui.
|
|
1322
1387
|
if (!parsed || (parsed.resource !== '/ws' && !isPanelResource(parsed.resource))) return reject(socket, 404);
|
|
1323
1388
|
if (isPanelResource(parsed.resource) && !panelAllowedFor(ingress)) return reject(socket, 403);
|
|
1389
|
+
// The same class decision as the HTTP relay: an upgrade is an entry point too,
|
|
1390
|
+
// and a limited peer must not reach an operator-class channel through it.
|
|
1391
|
+
{
|
|
1392
|
+
const gate = classGateCheck(parsed.resource, 'GET', ingress);
|
|
1393
|
+
if (!gate.ok) return reject(socket, gate.status === 404 ? 404 : 403);
|
|
1394
|
+
}
|
|
1324
1395
|
const st = store.loadStore(nodesPath);
|
|
1325
1396
|
if (!st) return reject(socket, 503);
|
|
1326
1397
|
const visited = controlledVisited(req, ingress, st.nodeId);
|
package/lib/proxy/node-proxy.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// lib/proxy/node-proxy.js —
|
|
2
|
+
// lib/proxy/node-proxy.js — single-origin reverse proxy for /node/<name>.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
4
|
+
// The MOST security-critical surface of the project. Hard contracts:
|
|
5
5
|
// 1. Il token LOCALE (Bearer) si verifica PRIMA di risolvere <name>. Il router
|
|
6
6
|
// HTTP e' montato DIETRO requireToken; l'upgrade WS verifica il token in testa
|
|
7
7
|
// a handleNodeUpgrade, prima di qualunque parsing/resolve.
|
|
8
8
|
// 2. <name> = chiave strict di nodes.json (^[a-z0-9-]{1,32}$), MAI usata per
|
|
9
9
|
// costruire path/URL filesystem; nome non in config -> 404 secco.
|
|
10
|
-
//
|
|
10
|
+
// 3. Il token del nodo remoto lo inietta SOLO il proxy (da nodes.json via); il
|
|
11
11
|
// browser non lo vede MAI. Header hop-by-hop e Authorization/cookie/host/
|
|
12
12
|
// x-forwarded/proxy-* client-supplied STRIPPATI, mai inoltrati upstream.
|
|
13
13
|
// 4. Upstream consentito: ESCLUSIVAMENTE 127.0.0.1:<localPort> del tunnel da
|
|
@@ -25,7 +25,7 @@ const net = require('node:net');
|
|
|
25
25
|
const { NODE_NAME_RE } = require('../nodes/store.js');
|
|
26
26
|
const { bearerFrom } = require('../auth/middleware.js');
|
|
27
27
|
|
|
28
|
-
//
|
|
28
|
+
// Upstream timeouts: unreachable/slow -> 502, never an endless spinner.
|
|
29
29
|
const PROXY_TIMEOUT_MS = 30000;
|
|
30
30
|
const CONNECT_TIMEOUT_MS = 10000;
|
|
31
31
|
|
|
@@ -52,7 +52,7 @@ const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
|
52
52
|
// forma di difetto che continuiamo a trovare.
|
|
53
53
|
const LOCAL_ONLY_PREFIXES = ['/api/live-host', '/api/panel', '/api/route', '/federation/route'];
|
|
54
54
|
|
|
55
|
-
// Hop-by-hop (RFC 7230
|
|
55
|
+
// Hop-by-hop (RFC 7230 section 6.1) + Proxy-*: never forwarded end-to-end.
|
|
56
56
|
const HOP_BY_HOP = new Set([
|
|
57
57
|
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
58
58
|
'te', 'trailer', 'transfer-encoding', 'upgrade',
|
|
@@ -153,9 +153,9 @@ function createNodeProxy(deps) {
|
|
|
153
153
|
const { resolveNode, readonly = () => false, httpRequest = http.request } = deps;
|
|
154
154
|
return function nodeProxy(req, res) {
|
|
155
155
|
const parsed = splitNodePath(req.url);
|
|
156
|
-
if (!parsed) return notFound(res); //
|
|
157
|
-
if (!NODE_NAME_RE.test(parsed.name)) return notFound(res); //
|
|
158
|
-
if (isTransitiveRest(parsed.rest)) return notFound(res); //
|
|
156
|
+
if (!parsed) return notFound(res); // no name
|
|
157
|
+
if (!NODE_NAME_RE.test(parsed.name)) return notFound(res); // strict/traversal
|
|
158
|
+
if (isTransitiveRest(parsed.rest)) return notFound(res); // no transitive rest
|
|
159
159
|
if (isLocalOnly(parsed.rest)) {
|
|
160
160
|
return res.status(403).json({ error: 'local-only: azione non instradabile via federazione' });
|
|
161
161
|
}
|
|
@@ -170,7 +170,7 @@ function createNodeProxy(deps) {
|
|
|
170
170
|
|
|
171
171
|
function proxyHttp(req, res, node, rest, search, httpRequest) {
|
|
172
172
|
const options = {
|
|
173
|
-
host: '127.0.0.1', //
|
|
173
|
+
host: '127.0.0.1', // upstream loopback ONLY, from config
|
|
174
174
|
port: node.localPort,
|
|
175
175
|
method: req.method,
|
|
176
176
|
// parita' col path WS: il token LOCALE eventualmente in query (?token=) non
|
|
@@ -278,10 +278,10 @@ function handleNodeUpgrade(ctx) {
|
|
|
278
278
|
const node = resolveNode(parsed.name);
|
|
279
279
|
if (!node) return abortUpgrade(socket, 404);
|
|
280
280
|
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
281
|
+
// In local READONLY the WS proxy refuses wholesale — raw piping cannot
|
|
282
|
+
// apply a frame-level readonly and a WS attach is a write channel (remote
|
|
283
|
+
// PTY). The remote node applies ITS OWN READONLY to its clients;
|
|
284
|
+
// here the local one rules, as for mutating HTTP methods.
|
|
285
285
|
if (readonly()) return abortUpgrade(socket, 403);
|
|
286
286
|
|
|
287
287
|
// (3)(4) inject token remoto, upstream SOLO loopback da config
|
package/lib/proxy/panel-auth.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// browser, nei log del proxy o in un Referer.
|
|
18
18
|
// 2. IL TICKET SI CONSUMA ALLA PRIMA RICHIESTA e la risposta imposta un cookie
|
|
19
19
|
// HttpOnly SameSite Strict con Path=ESATTAMENTE il mount da cui la
|
|
20
|
-
//
|
|
20
|
+
// Richiesta e' entrata + la cella (2026-08-17): `/api/panel/<cella>`
|
|
21
21
|
// sul control plane, `/panel/<cella>` sulla porta pannello dedicata — MAI
|
|
22
22
|
// una costante unica, perche' i due mount servono path diversi e un cookie
|
|
23
23
|
// scritto per l'uno non verrebbe mai mandato dal browser sotto l'altro
|
|
@@ -159,7 +159,7 @@ function createPanelAuth({
|
|
|
159
159
|
return !!rec && rec.exp > now() && rec.cell === cellId;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
//
|
|
162
|
+
// Il Path deve valere per il mount da cui la richiesta e' ENTRATA, non
|
|
163
163
|
// per una costante. Whitelist stretta invece di un default permissivo — un
|
|
164
164
|
// prefisso non riconosciuto rifiuta (vedi mountPrefixOf) invece di produrre
|
|
165
165
|
// un cookie con uno scope che nessuno ha verificato.
|
|
@@ -237,7 +237,7 @@ function createPanelAuth({
|
|
|
237
237
|
const qi = url.indexOf('?');
|
|
238
238
|
if (qi !== -1) qTicket = new URLSearchParams(url.slice(qi + 1)).get('ticket');
|
|
239
239
|
if (qTicket) {
|
|
240
|
-
//
|
|
240
|
+
// Il mount si verifica PRIMA di toccare il ticket — un mount non
|
|
241
241
|
// riconosciuto non deve bruciare un ticket monouso per un errore che
|
|
242
242
|
// non dipende da chi lo presenta.
|
|
243
243
|
const mountPrefix = mountPrefixOf(req);
|
|
@@ -318,7 +318,7 @@ function createPanelAuth({
|
|
|
318
318
|
consumeCore(req, res, next, cellId, provenienza);
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
-
//
|
|
321
|
+
// — SOLO consumo: per la porta pannello (sicurezza 2026-08-16). Nessuna
|
|
322
322
|
// emissione qui — quella resta dietro requireToken sul control plane, mai
|
|
323
323
|
// su una porta che non verifica un token. Un POST .../ticket arrivato qui
|
|
324
324
|
// (non dovrebbe mai: frameUrl non lo costruisce) cade nel consumo e viene
|
package/lib/proxy/panel-proxy.js
CHANGED
|
@@ -43,7 +43,7 @@ const { CELL_ID_RE } = require('../live-host/store.js');
|
|
|
43
43
|
|
|
44
44
|
const PANEL_TIMEOUT_MS = 30000;
|
|
45
45
|
|
|
46
|
-
// Hop-by-hop (RFC 7230
|
|
46
|
+
// Hop-by-hop (RFC 7230 section 6.1) + Proxy-*: never forwarded end-to-end.
|
|
47
47
|
const HOP_BY_HOP = new Set([
|
|
48
48
|
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
49
49
|
'te', 'trailer', 'transfer-encoding', 'upgrade',
|
|
@@ -295,7 +295,7 @@ function handlePanelUpgrade({ req, socket, head, resolveCellPanel, verifyToken,
|
|
|
295
295
|
return kill(401, 'Unauthorized');
|
|
296
296
|
}
|
|
297
297
|
// Il prefisso varia con la porta: /api/panel sul control plane (compat),
|
|
298
|
-
// /panel nudo sulla porta pannello dedicata (
|
|
298
|
+
// /panel nudo sulla porta pannello dedicata (sicurezza 2026-08-16).
|
|
299
299
|
const parsed = splitPanelPath(req.url.replace(/^\/(?:api\/)?panel/, ''));
|
|
300
300
|
if (!parsed) return kill(404, 'Not Found');
|
|
301
301
|
Promise.resolve(resolveTarget(resolveCellPanel, parsed.cellId)).then((target) => {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Classification of the resources reachable through the federation proxy.
|
|
4
|
+
//
|
|
5
|
+
// Every federated resource belongs to exactly one class, and each class demands
|
|
6
|
+
// explicit grants from the node that OWNS the resource. The class is decided on
|
|
7
|
+
// the resource and the method, BEFORE the request is dispatched: a route that is
|
|
8
|
+
// not classified is denied, so a new endpoint cannot become reachable by a
|
|
9
|
+
// limited peer just because nobody classified it. The gate reads grants — never
|
|
10
|
+
// a role string — and an owner-side request (no peer ingress) is unaffected.
|
|
11
|
+
|
|
12
|
+
const CLASSES = Object.freeze({
|
|
13
|
+
// Events of cells and of the node itself.
|
|
14
|
+
'event-feed': Object.freeze({ grants: ['eventsAccess'] }),
|
|
15
|
+
'node-event-feed': Object.freeze({ grants: ['eventsAccess', 'nodeEventsAccess'] }),
|
|
16
|
+
// Answering, dismissing or polling a single ask. Seeing the card does not
|
|
17
|
+
// grant the action.
|
|
18
|
+
'ask-action': Object.freeze({ grants: ['eventsAccess', 'askReplyAccess'] }),
|
|
19
|
+
// Inventories: which cells, which sessions, the status of the fleet. Only the
|
|
20
|
+
// authorized inventory is projected, and a peer with no visible cell sees none
|
|
21
|
+
// of it.
|
|
22
|
+
inventory: Object.freeze({ grants: [], needsVisibility: true }),
|
|
23
|
+
// Reading shared files: list and download, filtered by the owning cell.
|
|
24
|
+
'files-read': Object.freeze({ grants: ['filesReadAccess'] }),
|
|
25
|
+
// Choosing which cell of this node hosts the Live session.
|
|
26
|
+
'live-host': Object.freeze({ grants: ['liveHostAccess'] }),
|
|
27
|
+
// Opening a panel: a browser with already-authenticated sessions behind it.
|
|
28
|
+
panel: Object.freeze({ grants: ['panelAccess', 'peerOperatorAccess'], needsVisibility: true }),
|
|
29
|
+
// Everything else that mutates: configuration, filesystem browsing,
|
|
30
|
+
// diagnostics, credentials, fleet topology, decks, terminals, uploads and
|
|
31
|
+
// deletions. Operator-level by definition.
|
|
32
|
+
operator: Object.freeze({ grants: ['peerOperatorAccess'] }),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const CLASS_NAMES = Object.freeze(Object.keys(CLASSES));
|
|
36
|
+
|
|
37
|
+
const CELL_ID = '[A-Za-z0-9._-]{1,32}';
|
|
38
|
+
const NODE_ID = '[a-f0-9]{32}';
|
|
39
|
+
|
|
40
|
+
// Resource -> class. Keep this table aligned with the federation allowlist: the
|
|
41
|
+
// coverage test reads both and fails when one has a resource the other ignores.
|
|
42
|
+
function classifyResource(resource, method = 'GET') {
|
|
43
|
+
if (typeof resource !== 'string' || resource === '') return null;
|
|
44
|
+
const m = String(method || 'GET').toUpperCase();
|
|
45
|
+
|
|
46
|
+
if (resource === '/event-feed' || resource === '/event-feed/snapshot') {
|
|
47
|
+
return m === 'GET' ? 'event-feed' : null;
|
|
48
|
+
}
|
|
49
|
+
if (new RegExp(`^/event-feed/${CELL_ID}$`).test(resource)) return m === 'GET' ? 'event-feed' : null;
|
|
50
|
+
if (new RegExp(`^/event-feed/node/${NODE_ID}$`).test(resource)) return m === 'GET' ? 'node-event-feed' : null;
|
|
51
|
+
if (new RegExp(`^/asks/${CELL_ID}/(answer|dismiss|status)$`).test(resource)) {
|
|
52
|
+
return m === 'POST' || m === 'GET' ? 'ask-action' : null;
|
|
53
|
+
}
|
|
54
|
+
// Federated ask surface: the ask id is the 8-hex local id, the
|
|
55
|
+
// attempt id a UUID. The routes live UNDER /event-feed because they are the
|
|
56
|
+
// feed's own write-back path; the class is the same ask-action.
|
|
57
|
+
if (/^\/event-feed\/asks\/[a-f0-9]{8}\/answer$/.test(resource)) return m === 'POST' ? 'ask-action' : null;
|
|
58
|
+
if (/^\/event-feed\/asks\/[a-f0-9]{8}$/.test(resource)) return m === 'DELETE' ? 'ask-action' : null;
|
|
59
|
+
if (/^\/event-feed\/asks\/[a-f0-9]{8}\/requests\/[0-9a-f-]{16,64}$/.test(resource)) return m === 'GET' ? 'ask-action' : null;
|
|
60
|
+
|
|
61
|
+
if (resource === '/cells') return m === 'GET' ? 'inventory' : null;
|
|
62
|
+
if (resource === '/sessions') return m === 'GET' ? 'inventory' : (m === 'POST' ? 'operator' : null);
|
|
63
|
+
if (/^\/sessions\/[\w.@%:+-]{1,128}$/.test(resource)) return m === 'DELETE' ? 'operator' : null;
|
|
64
|
+
if (/^\/sessions\/[\w.@%:+-]{1,128}\/visibility$/.test(resource)) return m === 'PATCH' ? 'operator' : null;
|
|
65
|
+
if (resource === '/vl-nodes') return m === 'GET' ? 'inventory' : null;
|
|
66
|
+
if (new RegExp(`^/vl-nodes/${NODE_ID}/events$`).test(resource)) return m === 'GET' ? 'inventory' : null;
|
|
67
|
+
|
|
68
|
+
if (resource === '/files') return m === 'GET' ? 'files-read' : (m === 'DELETE' ? 'operator' : null);
|
|
69
|
+
if (resource === '/files/download') return m === 'GET' ? 'files-read' : null;
|
|
70
|
+
if (resource === '/files/upload') return m === 'POST' ? 'operator' : null;
|
|
71
|
+
|
|
72
|
+
if (resource === '/live-host') return m === 'GET' ? 'live-host' : null;
|
|
73
|
+
if (resource === '/live-host/designate' || resource === '/live-host/clear' || resource === '/live-host/bridge') {
|
|
74
|
+
return m === 'POST' ? 'live-host' : null;
|
|
75
|
+
}
|
|
76
|
+
if (new RegExp(`^/panel/${CELL_ID}(?:/.*)?$`).test(resource)) {
|
|
77
|
+
return m === 'GET' || m === 'POST' ? 'panel' : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Operator-level mutations and diagnostics, one by one.
|
|
81
|
+
if (resource === '/config') return m === 'GET' ? 'operator' : null;
|
|
82
|
+
if (resource === '/fs/dirs') return m === 'GET' ? 'operator' : null;
|
|
83
|
+
if (resource === '/cells/send') return m === 'POST' ? 'operator' : null;
|
|
84
|
+
if (resource === '/vl-nodes/invite') return m === 'POST' ? 'operator' : null;
|
|
85
|
+
if (new RegExp(`^/vl-nodes/${NODE_ID}/commands$`).test(resource)) return m === 'POST' ? 'operator' : null;
|
|
86
|
+
if (new RegExp(`^/vl-nodes/${NODE_ID}$`).test(resource)) return m === 'DELETE' ? 'operator' : null;
|
|
87
|
+
// Fleet: the allowlist splits the family in two — the read alternatives are
|
|
88
|
+
// GET, the rest are POST. A peer that is not an operator is refused by the
|
|
89
|
+
// class gate; an operator no longer gets "resource-not-classified".
|
|
90
|
+
if (/^\/fleet\/(status|schema|definitions|credentials\/status)$/.test(resource)) {
|
|
91
|
+
return m === 'GET' ? 'operator' : null;
|
|
92
|
+
}
|
|
93
|
+
if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) {
|
|
94
|
+
return m === 'POST' ? 'operator' : null;
|
|
95
|
+
}
|
|
96
|
+
if (resource === '/decks') return m === 'GET' || m === 'POST' ? 'operator' : null;
|
|
97
|
+
if (/^\/decks\/[a-z0-9-]{1,32}$/.test(resource)) {
|
|
98
|
+
return ['PUT', 'PATCH', 'DELETE'].includes(m) ? 'operator' : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Topology, diagnostics, terminals and the notify/audio surface of this node:
|
|
102
|
+
// all of them act ON the owner's node, so they belong to the operator class.
|
|
103
|
+
if (resource === '/topology') return m === 'GET' ? 'operator' : null;
|
|
104
|
+
// Diagnostics: the allowlist accepts GET on status and logs, DELETE on logs
|
|
105
|
+
// and PATCH on verbose — the class follows those methods, not a wider one.
|
|
106
|
+
if (resource === '/diagnostics/status') return m === 'GET' ? 'operator' : null;
|
|
107
|
+
if (resource === '/diagnostics/logs') return m === 'GET' || m === 'DELETE' ? 'operator' : null;
|
|
108
|
+
if (resource === '/diagnostics/verbose') return m === 'PATCH' ? 'operator' : null;
|
|
109
|
+
if (resource === '/ws') return m === 'GET' ? 'operator' : null;
|
|
110
|
+
if (resource === '/notify') return m === 'POST' ? 'operator' : null;
|
|
111
|
+
if (resource === '/audio/capability') return m === 'GET' ? 'operator' : null;
|
|
112
|
+
if (resource === '/audio/speak') return m === 'POST' ? 'operator' : null;
|
|
113
|
+
if (resource === '/audio/speak/status') return m === 'POST' ? 'operator' : null;
|
|
114
|
+
if (resource === '/audio/stop') return m === 'POST' ? 'operator' : null;
|
|
115
|
+
|
|
116
|
+
// Not classified: denied until it is classified on purpose.
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// True when the grants of the peer satisfy the class. `ingress` null is the
|
|
121
|
+
// owner of this node, who is not limited by these grants.
|
|
122
|
+
function allowedByClass(className, ingress) {
|
|
123
|
+
if (!ingress) return true;
|
|
124
|
+
const cls = CLASSES[className];
|
|
125
|
+
if (!cls) return false; // unknown class: never a reason to allow
|
|
126
|
+
for (const grant of cls.grants) {
|
|
127
|
+
if (ingress[grant] !== true) return false;
|
|
128
|
+
}
|
|
129
|
+
if (cls.needsVisibility === true && ingress.cellVisibility === 'none') return false;
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// The grants a class demands, for diagnostics and for the coverage test.
|
|
134
|
+
function grantsForClass(className) {
|
|
135
|
+
const cls = CLASSES[className];
|
|
136
|
+
return cls ? [...cls.grants] : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = {
|
|
140
|
+
CLASSES,
|
|
141
|
+
CLASS_NAMES,
|
|
142
|
+
allowedByClass,
|
|
143
|
+
classifyResource,
|
|
144
|
+
grantsForClass,
|
|
145
|
+
};
|