@mmmbuto/nexuscrew 0.9.28 → 0.9.30
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 +16 -0
- package/docs/LIVE_PROMPT.md +1 -1
- package/docs/NOTIFICATIONS.md +37 -0
- package/docs/OPERATIONS.md +20 -0
- package/frontend/dist/assets/index-BbcjoZS0.js +167 -0
- package/frontend/dist/assets/index-DqCfZFGi.css +32 -0
- 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 +70 -15
- package/lib/cli/doctor.js +5 -5
- package/lib/cli/fleet-service.js +7 -7
- package/lib/cli/init.js +55 -31
- package/lib/cli/pidfile.js +1 -1
- package/lib/cli/service.js +124 -8
- package/lib/cli/stable-alias.js +4 -4
- package/lib/cli/url.js +1 -1
- package/lib/config.js +36 -6
- package/lib/files/routes.js +1 -1
- package/lib/fleet/boot.js +2 -2
- package/lib/fleet/builtin.js +15 -15
- 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 +10 -10
- 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 +35 -35
- package/lib/fleet/prompt-delivery.js +13 -13
- package/lib/fleet/routes.js +15 -10
- package/lib/fleet/runtime.js +24 -24
- package/lib/live-host/bridge.js +53 -43
- package/lib/live-host/federation.js +97 -0
- package/lib/live-host/routes.js +166 -8
- package/lib/live-host/store.js +44 -6
- 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 +132 -12
- 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 +396 -40
- 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/skills/live/SKILL.md +12 -0
- package/frontend/dist/assets/index-CWra6B79.js +0 -167
- package/frontend/dist/assets/index-D6CIfCjv.css +0 -32
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
|
}
|
|
@@ -221,18 +231,31 @@ function panelAllowedFor(ingress) {
|
|
|
221
231
|
// che la possiede, non un dato che ogni peer pairato vede gia' — stesso gate
|
|
222
232
|
// per-peer del pannello, stesso default negato. A differenza del pannello (un
|
|
223
233
|
// sottoalbero /panel/<cella>/*, perche' serve HTML/JS/asset), qui l'insieme
|
|
224
|
-
// delle risorse e' chiuso:
|
|
225
|
-
const LIVE_HOST_RESOURCES = new Set(['/live-host', '/live-host/designate', '/live-host/clear']);
|
|
234
|
+
// delle risorse e' chiuso: quattro endpoint fissi, non un percorso arbitrario.
|
|
235
|
+
const LIVE_HOST_RESOURCES = new Set(['/live-host', '/live-host/designate', '/live-host/clear', '/live-host/bridge']);
|
|
226
236
|
function isLiveHostResource(resource) { return LIVE_HOST_RESOURCES.has(resource); }
|
|
227
237
|
function liveHostAllowedFor(ingress) {
|
|
228
238
|
if (!ingress) return true;
|
|
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';
|
|
235
255
|
if (resource === '/live-host/designate' || resource === '/live-host/clear') return method === 'POST';
|
|
256
|
+
// The bridge resolution travels to the owner without parameters: the
|
|
257
|
+
// designation picks the target, so a forwarded call cannot add one.
|
|
258
|
+
if (resource === '/live-host/bridge') return method === 'POST';
|
|
236
259
|
if (resource === '/sessions') return method === 'GET' || method === 'POST';
|
|
237
260
|
if (/^\/sessions\/[\w.@%:+-]{1,128}$/.test(resource)) return method === 'DELETE';
|
|
238
261
|
if (/^\/sessions\/[\w.@%:+-]{1,128}\/visibility$/.test(resource)) return method === 'PATCH';
|
|
@@ -270,6 +293,13 @@ function allowedResource(resource, method = 'GET') {
|
|
|
270
293
|
// Notify: solo POST. `/events`, `/asks` e `/push/*` restano NON federati —
|
|
271
294
|
// SSE non puo' autenticarsi col Bearer e un ask ha un canale di ritorno che
|
|
272
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';
|
|
273
303
|
if (resource === '/notify') return method === 'POST';
|
|
274
304
|
// Audio: only capability read, speak and stop are exposed through Hydra.
|
|
275
305
|
// audio.consent is a LOCAL mutation and MUST stay unreachable federated.
|
|
@@ -290,7 +320,27 @@ function readonlyBlocksFederated(resource, method) {
|
|
|
290
320
|
return resource !== '/audio/speak/status' && resource !== '/audio/stop';
|
|
291
321
|
}
|
|
292
322
|
|
|
323
|
+
const EVENT_FEED_CURSOR_RE = /^[0-9]{1,12}:[0-9]{1,12}$/;
|
|
324
|
+
|
|
293
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
|
+
}
|
|
294
344
|
if (!resource.startsWith('/diagnostics/')) return true;
|
|
295
345
|
const index = String(rawUrl || '').indexOf('?');
|
|
296
346
|
if (index < 0) return true;
|
|
@@ -364,17 +414,57 @@ function rewriteSetCookiePath(headers, prefix) {
|
|
|
364
414
|
return out;
|
|
365
415
|
}
|
|
366
416
|
|
|
367
|
-
|
|
417
|
+
// What this node records when a designation is created or removed on a PEER
|
|
418
|
+
// through it: `ownerId` is the peer the forward went to, and the revision is
|
|
419
|
+
// the owner's own, taken from its 2xx answer — never a local guess, because the
|
|
420
|
+
// owner is the one that decides. A non-2xx answer, or a body without
|
|
421
|
+
// `{hostCell, revision}`, records nothing: an absent pointer is honest, an
|
|
422
|
+
// invented one is not.
|
|
423
|
+
function liveHostLedgerCapture({ ledger, ownerId, resource }) {
|
|
424
|
+
return (status, text) => {
|
|
425
|
+
if (!ledger || !(status >= 200 && status < 300)) return;
|
|
426
|
+
if (resource === '/live-host/clear') {
|
|
427
|
+
if (typeof ledger.clearRemote === 'function') ledger.clearRemote(ownerId);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
let body = null;
|
|
431
|
+
try { body = JSON.parse(String(text || '')); } catch (_) { return; }
|
|
432
|
+
if (!body || typeof body.hostCell !== 'string' || !Number.isInteger(body.revision)) return;
|
|
433
|
+
if (typeof ledger.recordRemote === 'function') {
|
|
434
|
+
ledger.recordRemote({ ownerId, hostCell: body.hostCell, revision: body.revision });
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null, setCookiePrefix = null, panelCookie = null, capture = null }) {
|
|
440
|
+
// A body a local middleware has already read is no longer in the stream: the
|
|
441
|
+
// headers would announce bytes that nothing sends, and the peer would wait for
|
|
442
|
+
// a body that never arrives. Say it at once, with a name, instead of letting
|
|
443
|
+
// the forward sit until the upstream timeout expires.
|
|
444
|
+
if (Number(req.headers['content-length'] || 0) > 0 && req.readableEnded) {
|
|
445
|
+
return res.status(502).json({ error: 'request body already read by a local middleware', reason: 'federation-body-consumed' });
|
|
446
|
+
}
|
|
368
447
|
const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited, hopProof, panelCookie) }, (r) => {
|
|
369
448
|
const headers = setCookiePrefix ? rewriteSetCookiePath(r.headers, setCookiePrefix) : r.headers;
|
|
370
|
-
res.writeHead(r.statusCode, sanitizeResponseHeaders(headers)); r.pipe(res);
|
|
449
|
+
if (!capture) { res.writeHead(r.statusCode, sanitizeResponseHeaders(headers)); r.pipe(res); return; }
|
|
450
|
+
// Buffered only where a capture asks for it (the two live-host mutations):
|
|
451
|
+
// the owner's answer carries the revision that has to be recorded.
|
|
452
|
+
const chunks = [];
|
|
453
|
+
r.on('data', (chunk) => chunks.push(chunk));
|
|
454
|
+
r.on('end', () => {
|
|
455
|
+
const body = Buffer.concat(chunks);
|
|
456
|
+
try { capture(r.statusCode, body.toString('utf8')); } catch (_) { /* the ledger never breaks the forward */ }
|
|
457
|
+
if (res.writableEnded) return;
|
|
458
|
+
res.writeHead(r.statusCode, sanitizeResponseHeaders(headers));
|
|
459
|
+
res.end(body);
|
|
460
|
+
});
|
|
371
461
|
});
|
|
372
462
|
up.setTimeout(30000, () => up.destroy());
|
|
373
|
-
up.on('error', () => { if (!res.headersSent) res.status(502).json({ error: 'peer non raggiungibile' }); else res.destroy(); });
|
|
463
|
+
up.on('error', () => { if (!res.headersSent) res.status(502).json({ error: 'peer non raggiungibile', reason: 'federation-peer-unreachable' }); else res.destroy(); });
|
|
374
464
|
req.pipe(up);
|
|
375
465
|
}
|
|
376
466
|
|
|
377
|
-
function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false, hopSecret = null }) {
|
|
467
|
+
function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false, hopSecret = null, liveHostLedger = null }) {
|
|
378
468
|
return (req, res) => {
|
|
379
469
|
const parsed = parseRoute(req.url);
|
|
380
470
|
// Prima dell'allowlist: un peer senza permesso non deve nemmeno sapere se la
|
|
@@ -385,6 +475,20 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
|
|
|
385
475
|
if (parsed && isLiveHostResource(parsed.resource) && !liveHostAllowedFor(ingress)) {
|
|
386
476
|
return res.status(403).json({ error: 'cella ospite live non concessa da questo nodo', reason: 'live-host-not-granted' });
|
|
387
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
|
+
}
|
|
388
492
|
if (!parsed || !allowedResource(parsed.resource, req.method)
|
|
389
493
|
|| !allowedQuery(parsed.resource, req.method, req.url)) return res.status(404).json({ error: 'not found' });
|
|
390
494
|
if (readonly() && readonlyBlocksFederated(parsed.resource, req.method)) return res.status(403).json({ error: 'READONLY: federated mutation blocked' });
|
|
@@ -422,7 +526,13 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
|
|
|
422
526
|
const setCookiePrefix = !ingress && isPanelResource(parsed.resource)
|
|
423
527
|
? `/api/route/${parsed.route.join('/')}/${ROUTE_DELIMITER}`
|
|
424
528
|
: null;
|
|
425
|
-
|
|
529
|
+
// A designation forwarded from here lands on the owner: what this node ends
|
|
530
|
+
// up pointing at is decided THERE, so the pointer is recorded from the
|
|
531
|
+
// owner's answer (liveHostLedgerCapture) and not from a local guess.
|
|
532
|
+
const capture = liveHostLedger && (parsed.resource === '/live-host/designate' || parsed.resource === '/live-host/clear')
|
|
533
|
+
? liveHostLedgerCapture({ ledger: liveHostLedger, ownerId: next.nodeId, resource: parsed.resource })
|
|
534
|
+
: null;
|
|
535
|
+
proxyHttp(req, res, { port: next.localPort, path, credential: next.token, visited, setCookiePrefix, panelCookie, capture });
|
|
426
536
|
};
|
|
427
537
|
}
|
|
428
538
|
|
|
@@ -748,7 +858,7 @@ async function reconcilePeerShare(opts = {}) {
|
|
|
748
858
|
throw lastError || new Error('riconciliazione Share fallita');
|
|
749
859
|
}
|
|
750
860
|
|
|
751
|
-
//
|
|
861
|
+
// Share-off reconciliation runner at boot.
|
|
752
862
|
// no-overlap (un solo runner attivo per nome), al massimo tre round per processo,
|
|
753
863
|
// backoff nominato 0/1000/5000 ms (iniettabile). Re-read dello stato desiderato
|
|
754
864
|
// (nodes.json) prima di ogni round: abort se non e' piu' shared:false (es. il peer
|
|
@@ -995,7 +1105,11 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
995
1105
|
r.get('/health', (_req, res) => {
|
|
996
1106
|
const st = store.loadStore(nodesPath);
|
|
997
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.
|
|
998
1111
|
res.json({ ok: true, instanceId: (st && st.nodeId) || null, version, readonly: !!readonly(),
|
|
1112
|
+
eventFeedV1: true,
|
|
999
1113
|
...(advertisedRoles ? { roles: advertisedRoles } : {}) });
|
|
1000
1114
|
});
|
|
1001
1115
|
// Il client puo' verificare SOLO la reverse port che il pairing gli ha
|
|
@@ -1257,9 +1371,9 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
1257
1371
|
return r;
|
|
1258
1372
|
}
|
|
1259
1373
|
|
|
1260
|
-
function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecret = null }) {
|
|
1374
|
+
function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecret = null, liveHostLedger = null }) {
|
|
1261
1375
|
const r = express.Router();
|
|
1262
|
-
r.use((req, res) => routeHandler({ nodesPath, localPort, localCredential, readonly, hopSecret })(req, res));
|
|
1376
|
+
r.use((req, res) => routeHandler({ nodesPath, localPort, localCredential, readonly, hopSecret, liveHostLedger })(req, res));
|
|
1263
1377
|
return r;
|
|
1264
1378
|
}
|
|
1265
1379
|
|
|
@@ -1272,6 +1386,12 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1272
1386
|
// frame arrivano da qui.
|
|
1273
1387
|
if (!parsed || (parsed.resource !== '/ws' && !isPanelResource(parsed.resource))) return reject(socket, 404);
|
|
1274
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
|
+
}
|
|
1275
1395
|
const st = store.loadStore(nodesPath);
|
|
1276
1396
|
if (!st) return reject(socket, 503);
|
|
1277
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) => {
|