@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/nodes/store.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// lib/nodes/store.js — nodes.json secret store
|
|
2
|
+
// lib/nodes/store.js — nodes.json secret store.
|
|
3
3
|
//
|
|
4
4
|
// nodes.json e' un SECRET STORE, non config qualunque: contiene i token dei nodi
|
|
5
|
-
//
|
|
5
|
+
// Remoti (iniettati dal proxy in). Percio' riusa lo stesso hardening di
|
|
6
6
|
// lib/fleet/definitions.js e del token file:
|
|
7
7
|
// - permessi 0600, scrittura atomica (tmp stessa dir + rename), rifiuto symlink
|
|
8
8
|
// - schema STRICT validato al load: garbage -> errore esplicito, MAI guess
|
|
@@ -19,6 +19,7 @@ const path = require('node:path');
|
|
|
19
19
|
const crypto = require('node:crypto');
|
|
20
20
|
const reversePool = require('./reverse-pool.js');
|
|
21
21
|
const identity = require('./identity.js');
|
|
22
|
+
const accessPresets = require('./access-presets.js');
|
|
22
23
|
|
|
23
24
|
const SCHEMA_VERSION = 3;
|
|
24
25
|
const PREVIOUS_SCHEMA_VERSION = 2;
|
|
@@ -28,14 +29,14 @@ const MAX_TOKEN_LEN = 4096;
|
|
|
28
29
|
const MAX_KEYPATH_LEN = 4096;
|
|
29
30
|
const MAX_SSH_LEN = 320; // user(<=32) + '@' + host(<=255)
|
|
30
31
|
|
|
31
|
-
// name:
|
|
32
|
-
//
|
|
32
|
+
// name: strict key also used as a path/route segment -> no '.', no '/',
|
|
33
|
+
// no uppercase. Aligned with the strict name contract (^[a-z0-9-]{1,32}$).
|
|
33
34
|
const NODE_NAME_RE = /^[a-z0-9-]{1,32}$/;
|
|
34
35
|
// nodeId: id stabile per-installazione, hex casuale (crypto.randomBytes(16)).
|
|
35
36
|
const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
|
|
36
37
|
// user@host: entrambe le parti argv-safe (no spazi, no leading '-' -> mai
|
|
37
38
|
// interpretabile come opzione ssh). Host: hostname/FQDN/IPv4 (niente ':' -> IPv6
|
|
38
|
-
//
|
|
39
|
+
// Va usato via Host alias in ~/.ssh/config, fuori scope).
|
|
39
40
|
const SSH_USER_RE = /^[A-Za-z0-9._-]{1,32}$/;
|
|
40
41
|
const SSH_HOST_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/;
|
|
41
42
|
|
|
@@ -112,6 +113,10 @@ const NODE_KEYS = new Set([
|
|
|
112
113
|
// Quali CELLE di questo nodo il peer puo' vedere. Distinto da `visibility`,
|
|
113
114
|
// che governa il TRANSITO (attraverso chi passa il traffico) e non l'accesso.
|
|
114
115
|
'cellVisibility', 'cells',
|
|
116
|
+
// eventsReceive: the CLIENT's own local choice to import an owner's feed.
|
|
117
|
+
// It is NOT a grant the owner sets, it never travels in a preset, and it
|
|
118
|
+
// only ever decides what THIS node pulls — never what a peer may read here.
|
|
119
|
+
'eventsReceive',
|
|
115
120
|
// Se questo peer puo' aprire i PANNELLI delle celle di questo nodo. Distinto
|
|
116
121
|
// da `cellVisibility`, che dice quali celle il peer VEDE: dietro un pannello
|
|
117
122
|
// c'e' un browser con sessioni gia' autenticate, e un accesso di quel tipo non
|
|
@@ -132,13 +137,25 @@ const NODE_KEYS = new Set([
|
|
|
132
137
|
// grant potra' pretendere la prima. `keyConflict` conserva una chiave DIVERSA
|
|
133
138
|
// vista dopo il legame, senza sostituire quella legata.
|
|
134
139
|
'publicKey', 'keySource', 'keyBoundAt', 'keyConflict',
|
|
135
|
-
//
|
|
140
|
+
// Stessa-origin, meta' remota: la porta pannello del peer inoltrata sul
|
|
136
141
|
// NOSTRO loopback (panelLocalPort) e la sua origine remota (panelRemotePort).
|
|
137
142
|
// COPPIA obbligatoria: un -L ha due estremita', mezza coppia e' un forward
|
|
138
143
|
// impossibile, non "meno pannello". Campi assenti = peer accoppiato prima di
|
|
139
144
|
// questa negoziazione: via storica, nessun comportamento nuovo. Le porte non
|
|
140
145
|
// sono segreti (come localPort/remotePort, che stanno qui accanto).
|
|
141
146
|
'panelLocalPort', 'panelRemotePort',
|
|
147
|
+
// Federated access grants: what this peer may do with the resources this node
|
|
148
|
+
// OWNS (events, asks, files) beyond cell visibility. Same shape and same rule
|
|
149
|
+
// as panelAccess/liveHostAccess: default denied, one node at a time. A peer
|
|
150
|
+
// does not gain them by being paired, and the receiving client's own choice
|
|
151
|
+
// to receive events is NOT one of them (it is local to that client).
|
|
152
|
+
'eventsAccess', 'nodeEventsAccess', 'askReplyAccess', 'filesReadAccess', 'peerOperatorAccess',
|
|
153
|
+
// Whether the grant vector was set EXPLICITLY. Grants alone cannot tell a
|
|
154
|
+
// legacy record (all defaults denied) from a deliberate vector that happens
|
|
155
|
+
// to deny everything: without this marker a legacy peer would read as a
|
|
156
|
+
// coherent "custom" preset instead of "not configured". It is provenance,
|
|
157
|
+
// not a permission — it grants nothing and handlers never read it as a role.
|
|
158
|
+
'accessConfigured',
|
|
142
159
|
]);
|
|
143
160
|
|
|
144
161
|
// Nome di cella: stessa forma usata da fleet/definitions.js, cells/routes.js e
|
|
@@ -248,6 +265,7 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
248
265
|
const roles = parseRoles(n.roles);
|
|
249
266
|
if (!roles) return null;
|
|
250
267
|
if (n.rolesKnown !== undefined && typeof n.rolesKnown !== 'boolean') return null;
|
|
268
|
+
if (n.eventsReceive !== undefined && typeof n.eventsReceive !== 'boolean') return null;
|
|
251
269
|
// Before 0.8.9 `roles` was filled with a local default and did not describe
|
|
252
270
|
// the remote peer. Only the explicit marker makes role-based health safe.
|
|
253
271
|
const rolesKnown = n.rolesKnown === true;
|
|
@@ -285,6 +303,16 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
285
303
|
// Stessa regola, stesso motivo: un record senza il campo non acquisisce la
|
|
286
304
|
// designazione live per anzianita' del pairing.
|
|
287
305
|
liveHostAccess: n.liveHostAccess === undefined ? false : n.liveHostAccess,
|
|
306
|
+
// Le cinque concessioni federate seguono la stessa regola: assenti valgono
|
|
307
|
+
// NEGATE. Nessuna migrazione silenziosa al load: il vettore va impostato da
|
|
308
|
+
// un atto esplicito (preset o edit granulare), altrimenti resta negato.
|
|
309
|
+
eventsReceive: n.eventsReceive === undefined ? false : n.eventsReceive === true,
|
|
310
|
+
eventsAccess: n.eventsAccess === undefined ? false : n.eventsAccess,
|
|
311
|
+
nodeEventsAccess: n.nodeEventsAccess === undefined ? false : n.nodeEventsAccess,
|
|
312
|
+
askReplyAccess: n.askReplyAccess === undefined ? false : n.askReplyAccess,
|
|
313
|
+
filesReadAccess: n.filesReadAccess === undefined ? false : n.filesReadAccess,
|
|
314
|
+
peerOperatorAccess: n.peerOperatorAccess === undefined ? false : n.peerOperatorAccess,
|
|
315
|
+
accessConfigured: n.accessConfigured === true,
|
|
288
316
|
visibility: n.visibility || 'network',
|
|
289
317
|
};
|
|
290
318
|
if (!['auto', 'ssh', 'autossh', 'inbound'].includes(out.transport)) return null;
|
|
@@ -294,6 +322,12 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
294
322
|
if (typeof out.shared !== 'boolean') return null;
|
|
295
323
|
if (typeof out.panelAccess !== 'boolean') return null;
|
|
296
324
|
if (typeof out.liveHostAccess !== 'boolean') return null;
|
|
325
|
+
if (typeof out.eventsAccess !== 'boolean') return null;
|
|
326
|
+
if (typeof out.nodeEventsAccess !== 'boolean') return null;
|
|
327
|
+
if (typeof out.askReplyAccess !== 'boolean') return null;
|
|
328
|
+
if (typeof out.filesReadAccess !== 'boolean') return null;
|
|
329
|
+
if (typeof out.peerOperatorAccess !== 'boolean') return null;
|
|
330
|
+
if (typeof out.accessConfigured !== 'boolean') return null;
|
|
297
331
|
if (!['network', 'relay-only', 'selected'].includes(out.visibility)) return null;
|
|
298
332
|
if (identityFile) out.identityFile = identityFile;
|
|
299
333
|
// Keep the old public field while reading v1 so old callers/tests and a
|
|
@@ -303,7 +337,7 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
303
337
|
if (!isPort(n.reversePort)) return null;
|
|
304
338
|
out.reversePort = n.reversePort;
|
|
305
339
|
}
|
|
306
|
-
// Coppia pannello (
|
|
340
|
+
// Coppia pannello (stessa-origin remota): o entrambe le porte o nessuna.
|
|
307
341
|
// Una sola meta' non viene indovinata — sarebbe un -L monco. E la porta
|
|
308
342
|
// locale del pannello non puo' stare su quella del control plane: ssh
|
|
309
343
|
// cadrebbe con ExitOnForwardFailure portandosi via il tunnel intero.
|
|
@@ -427,7 +461,7 @@ function parseStore(raw) {
|
|
|
427
461
|
|
|
428
462
|
if (![SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, LEGACY_SCHEMA_VERSION].includes(d.schemaVersion)) return null;
|
|
429
463
|
const rootKeys = d.schemaVersion === SCHEMA_VERSION
|
|
430
|
-
? new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous', 'reversePoolAnchor', 'reversePoolLedgerInitialized'])
|
|
464
|
+
? new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous', 'reversePoolAnchor', 'reversePoolLedgerInitialized', 'accessRevision'])
|
|
431
465
|
: new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous']);
|
|
432
466
|
if (Object.keys(d).some((key) => !rootKeys.has(key))) return null;
|
|
433
467
|
if (typeof d.nodeId !== 'string' || !NODE_ID_RE.test(d.nodeId)) return null;
|
|
@@ -461,6 +495,16 @@ function parseStore(raw) {
|
|
|
461
495
|
|
|
462
496
|
const out = { schemaVersion: d.schemaVersion, nodeId: d.nodeId, nodes };
|
|
463
497
|
|
|
498
|
+
// Revision of the peer access policy. Absent means 0: a store written
|
|
499
|
+
// before this field existed is at revision 0 and the first explicit write
|
|
500
|
+
// advances it to 1. It is kept out of the parsed object when absent so a
|
|
501
|
+
// plain load never rewrites the file with a field nobody set.
|
|
502
|
+
if (d.accessRevision !== undefined) {
|
|
503
|
+
if (d.schemaVersion !== SCHEMA_VERSION) return null;
|
|
504
|
+
if (!Number.isInteger(d.accessRevision) || d.accessRevision < 0) return null;
|
|
505
|
+
out.accessRevision = d.accessRevision;
|
|
506
|
+
}
|
|
507
|
+
|
|
464
508
|
if (d.rendezvous !== undefined && d.rendezvous !== null) {
|
|
465
509
|
const rdv = parseRendezvous(d.rendezvous);
|
|
466
510
|
if (!rdv) return null;
|
|
@@ -647,6 +691,101 @@ function updateNode(store, name, patch) {
|
|
|
647
691
|
return { ...store, schemaVersion: mutationSchemaVersion(store), nodes };
|
|
648
692
|
}
|
|
649
693
|
|
|
694
|
+
// --- Concessioni federate: preset, CAS, inventario -------------------------
|
|
695
|
+
|
|
696
|
+
function accessRevisionOf(store) {
|
|
697
|
+
return store && Number.isInteger(store.accessRevision) && store.accessRevision >= 0
|
|
698
|
+
? store.accessRevision : 0;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Pure: returns a new store with the peer's grant vector replaced by a
|
|
702
|
+
// validated one. The vector is validated as a whole, so an edit that keeps
|
|
703
|
+
// live hosting while dropping another admin grant is refused here too.
|
|
704
|
+
function setPeerAccessGrants(store, name, grants) {
|
|
705
|
+
const idx = store.nodes.findIndex((n) => n.name === name);
|
|
706
|
+
if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
|
|
707
|
+
const check = accessPresets.validateGrants(grants);
|
|
708
|
+
if (!check.ok) throw new Error(`concessioni non valide: ${check.errors.join('; ')}`);
|
|
709
|
+
const next = { ...store.nodes[idx] };
|
|
710
|
+
next.cellVisibility = grants.cellVisibility;
|
|
711
|
+
if (grants.cellVisibility === 'selected') next.cells = [...(grants.cells || [])];
|
|
712
|
+
else delete next.cells;
|
|
713
|
+
for (const key of accessPresets.BOOLEAN_GRANTS) next[key] = grants[key] === true;
|
|
714
|
+
next.accessConfigured = true;
|
|
715
|
+
const nodes = store.nodes.slice();
|
|
716
|
+
nodes[idx] = next;
|
|
717
|
+
const out = { ...store, schemaVersion: SCHEMA_VERSION, nodes };
|
|
718
|
+
out.accessRevision = accessRevisionOf(store) + 1;
|
|
719
|
+
return out;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Pure: applies a named preset to one peer.
|
|
723
|
+
function setPeerAccessPreset(store, name, presetName) {
|
|
724
|
+
const idx = store.nodes.findIndex((n) => n.name === name);
|
|
725
|
+
if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
|
|
726
|
+
const applied = accessPresets.applyPreset(store.nodes[idx], presetName);
|
|
727
|
+
const vector = { cellVisibility: applied.cellVisibility };
|
|
728
|
+
if (applied.cells !== undefined) vector.cells = applied.cells;
|
|
729
|
+
for (const key of accessPresets.BOOLEAN_GRANTS) vector[key] = applied[key];
|
|
730
|
+
return setPeerAccessGrants(store, name, vector);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Read-only inventory: what every peer is allowed to do on THIS node, with the
|
|
734
|
+
// label derived from the grants. Used by `nodes show`, by the settings view and
|
|
735
|
+
// by the explicit migration: a legacy peer shows up as not configured, never as
|
|
736
|
+
// a preset inferred from defaults.
|
|
737
|
+
function accessInventory(store) {
|
|
738
|
+
const peers = (store && store.nodes ? store.nodes : []).map((peer) => {
|
|
739
|
+
const view = accessPresets.grantsOf(peer);
|
|
740
|
+
return {
|
|
741
|
+
name: peer.name,
|
|
742
|
+
nodeId: peer.nodeId || null,
|
|
743
|
+
direction: peer.direction,
|
|
744
|
+
configured: view.configured,
|
|
745
|
+
label: view.label,
|
|
746
|
+
grants: view.grants,
|
|
747
|
+
};
|
|
748
|
+
});
|
|
749
|
+
return { revision: accessRevisionOf(store), peers };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Compare-and-set write for the peer access policy: one validated read-modify-
|
|
753
|
+
// write over the store file. `expectedRevision` must be an integer that matches
|
|
754
|
+
// the current revision, otherwise the write is refused and the caller rereads
|
|
755
|
+
// and retries — never a permissive default. The whole path is synchronous, so
|
|
756
|
+
// two callers in this process cannot interleave between the read and the write.
|
|
757
|
+
function setPeerAccessGrantsCas({ filePath, name, grants, expectedRevision, extraPatch }) {
|
|
758
|
+
const current = loadStoreStrict(filePath);
|
|
759
|
+
if (!Number.isInteger(expectedRevision) || accessRevisionOf(current) !== expectedRevision) {
|
|
760
|
+
return { ok: false, conflict: true, revision: accessRevisionOf(current) };
|
|
761
|
+
}
|
|
762
|
+
let next = current;
|
|
763
|
+
if (extraPatch && Object.keys(extraPatch).length > 0) next = updateNode(next, name, extraPatch);
|
|
764
|
+
next = setPeerAccessGrants(next, name, grants);
|
|
765
|
+
const written = atomicWriteStore(filePath, next);
|
|
766
|
+
return {
|
|
767
|
+
ok: true,
|
|
768
|
+
name,
|
|
769
|
+
revision: accessRevisionOf(written),
|
|
770
|
+
grants: accessPresets.grantsOf(written.nodes.find((n) => n.name === name)).grants,
|
|
771
|
+
label: accessPresets.grantsOf(written.nodes.find((n) => n.name === name)).label,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Same, by preset name.
|
|
776
|
+
function setPeerAccessPresetCas({ filePath, name, presetName, expectedRevision, extraPatch }) {
|
|
777
|
+
const current = loadStoreStrict(filePath);
|
|
778
|
+
if (!Number.isInteger(expectedRevision) || accessRevisionOf(current) !== expectedRevision) {
|
|
779
|
+
return { ok: false, conflict: true, revision: accessRevisionOf(current) };
|
|
780
|
+
}
|
|
781
|
+
let next = current;
|
|
782
|
+
if (extraPatch && Object.keys(extraPatch).length > 0) next = updateNode(next, name, extraPatch);
|
|
783
|
+
next = setPeerAccessPreset(next, name, presetName);
|
|
784
|
+
const written = atomicWriteStore(filePath, next);
|
|
785
|
+
const view = accessPresets.grantsOf(written.nodes.find((n) => n.name === name));
|
|
786
|
+
return { ok: true, name, revision: accessRevisionOf(written), grants: view.grants, label: view.label };
|
|
787
|
+
}
|
|
788
|
+
|
|
650
789
|
// The upgrade is explicit: normal legacy pairing remains schema v2 and keeps
|
|
651
790
|
// working with a previous NexusCrew binary. Enabling rotation is the only
|
|
652
791
|
// path that writes the v3 anchor and therefore requires both peers to support
|
|
@@ -691,6 +830,7 @@ function redactNode(n) {
|
|
|
691
830
|
rolesKnown: n.rolesKnown === true,
|
|
692
831
|
transport: n.transport || 'ssh',
|
|
693
832
|
autostart: !!n.autostart,
|
|
833
|
+
eventsReceive: n.eventsReceive === true,
|
|
694
834
|
shared: n.shared === true,
|
|
695
835
|
panelAccess: n.panelAccess === true,
|
|
696
836
|
liveHostAccess: n.liveHostAccess === true,
|
|
@@ -758,7 +898,7 @@ function hasPairedPeers(store) {
|
|
|
758
898
|
}
|
|
759
899
|
|
|
760
900
|
// --- Migrazione esplicita da config.json (guarded, no-op se assente) ---------
|
|
761
|
-
// Se config.json contiene un array `nodes` legacy (vecchio formato pre-
|
|
901
|
+
// Se config.json contiene un array `nodes` legacy (vecchio formato pre-),
|
|
762
902
|
// lo importa in nodes.json. Guarded: no-op se config.json non ha `nodes`, o se
|
|
763
903
|
// nodes.json ha gia' dei nodi (mai overwrite). Strict: un nodo legacy malformato
|
|
764
904
|
// -> throw esplicito (non importa silenziosamente spazzatura).
|
|
@@ -885,6 +1025,9 @@ module.exports = {
|
|
|
885
1025
|
defaultNodesPath, loadStore, loadStoreStrict, initStore, atomicWriteStore, loadOrInitStore, emptyStore, newNodeId,
|
|
886
1026
|
// mutazioni
|
|
887
1027
|
getNode, addNode, removeNode, setNodeToken, updateNode, upgradeToReversePoolSchema, setNodeReversePool,
|
|
1028
|
+
// concessioni federate (preset, CAS, inventario)
|
|
1029
|
+
accessRevisionOf, setPeerAccessGrants, setPeerAccessPreset, accessInventory,
|
|
1030
|
+
setPeerAccessGrantsCas, setPeerAccessPresetCas,
|
|
888
1031
|
// redazione
|
|
889
1032
|
redactNode, redactStore, hasPairedPeers,
|
|
890
1033
|
// migrazione
|
package/lib/nodes/tunnel.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// lib/nodes/tunnel.js — SSH tunnel manager
|
|
2
|
+
// lib/nodes/tunnel.js — SSH tunnel manager.
|
|
3
3
|
//
|
|
4
4
|
// I processi ssh sono gestiti da un solo supervisor detached, avviato dal
|
|
5
5
|
// lifecycle del servizio o dalle azioni PWA autenticate. argv puro (spawn con
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// unit-testabili senza lanciare ssh.
|
|
11
11
|
// - lifecycle per-tunnel via pidfile (start/stop/restart/state): il processo
|
|
12
12
|
// ssh e' detached, la sua liveness/porta stabile e' interrogabile da
|
|
13
|
-
//
|
|
13
|
+
// `status --json` e (in) dal proxy. Porta STABILE da nodes.json; una
|
|
14
14
|
// collisione fa fallire ExitOnForwardFailure -> si segnala, NON si riciclano
|
|
15
15
|
// porte a caso.
|
|
16
16
|
const fs = require('node:fs');
|
|
@@ -21,7 +21,7 @@ const { spawn, spawnSync } = require('node:child_process');
|
|
|
21
21
|
const pidf = require('../cli/pidfile.js');
|
|
22
22
|
const store = require('./store.js');
|
|
23
23
|
|
|
24
|
-
//
|
|
24
|
+
// SSH options shared by both templates (exact order and values).
|
|
25
25
|
const SSH_BASE_OPTS = Object.freeze([
|
|
26
26
|
'-N',
|
|
27
27
|
'-o', 'ExitOnForwardFailure=yes',
|
|
@@ -44,7 +44,7 @@ function assertForwardSpec(node) {
|
|
|
44
44
|
if (node.keyPath !== undefined && !store.isAbsPath(node.keyPath)) throw new Error('tunnel: keyPath non valido');
|
|
45
45
|
if (node.reversePort !== undefined && !store.isPort(node.reversePort)) throw new Error('tunnel: reversePort non valida');
|
|
46
46
|
if (node.localAppPort !== undefined && !store.isPort(node.localAppPort)) throw new Error('tunnel: localAppPort non valida');
|
|
47
|
-
// Coppia pannello (
|
|
47
|
+
// Coppia pannello (stessa-origin remota): o entrambe o nessuna, e mai
|
|
48
48
|
// sulla porta locale del control plane — il bind conflitto abbatterebbe il
|
|
49
49
|
// tunnel intero con ExitOnForwardFailure.
|
|
50
50
|
if (node.panelLocalPort !== undefined || node.panelRemotePort !== undefined) {
|
|
@@ -61,7 +61,7 @@ function assertForwardSpec(node) {
|
|
|
61
61
|
if (!store.parseSshTarget(node.ssh)) throw new Error('tunnel: target ssh non valido');
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
// Forward client->
|
|
64
|
+
// Forward client->node (client role):
|
|
65
65
|
// ssh -N -o ExitOnForwardFailure=yes -o ServerAliveInterval=30
|
|
66
66
|
// -o ServerAliveCountMax=3 -o BatchMode=yes -i <key>
|
|
67
67
|
// -L 127.0.0.1:<locale>:127.0.0.1:<remota> user@host
|
|
@@ -108,7 +108,7 @@ function buildReverseArgs(node, { remotePort, targetPort } = {}) {
|
|
|
108
108
|
]);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
//
|
|
111
|
+
// Exponential backoff + jitter. Deterministic with an injected rng.
|
|
112
112
|
// delay = clamp(base * factor^attempt, 0..cap) * (1 +- jitter*(2*rand-1))
|
|
113
113
|
// rng()=0.5 -> jitter nullo (ritorna il valore base clamperato); test deterministici.
|
|
114
114
|
function backoffDelay(attempt, opts = {}) {
|
|
@@ -735,7 +735,7 @@ function authorizedKeysLine({ remotePort, panelRemotePort, pub } = {}) {
|
|
|
735
735
|
// registrato in revisione: `null | stringa` comprimeva stati DIVERSI, e il
|
|
736
736
|
// livello basso componeva frasi su fatti che non conosce — meta' della 0.9.5
|
|
737
737
|
// e' nata li'). Gli esiti sono DATI, senza testo per l'utente: le frasi si
|
|
738
|
-
//
|
|
738
|
+
// Compongono al confine UI/log, mai qui. Si aggancia a: una chiave cifrata
|
|
739
739
|
// dava `null`, indistinguibile da «file illeggibile», e il pairing ometteva
|
|
740
740
|
// la riga in silenzio.
|
|
741
741
|
//
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/notify/ask-answer-service.js — ONE answer cycle for BOTH the local route
|
|
3
|
+
// and the federated one. There is no second path: claim (synchronous) ->
|
|
4
|
+
// paste to the session FROM THE STORE (never from a body) -> commit or a
|
|
5
|
+
// declared failure -> a qualified closure event (askId, revision, cell).
|
|
6
|
+
//
|
|
7
|
+
// Federated attempts additionally carry a durable receipt (written BEFORE the
|
|
8
|
+
// paste) and an idempotency contract: the same requestId with the same text
|
|
9
|
+
// after a timeout returns the stored state WITHOUT pasting again; the same id
|
|
10
|
+
// with a different text is a 409. A locked ask (delivery-unknown from a
|
|
11
|
+
// previous crash) refuses new pastes on EVERY path until the operator
|
|
12
|
+
// reconciles explicitly: no silent retries, no TTL.
|
|
13
|
+
|
|
14
|
+
const crypto = require('node:crypto');
|
|
15
|
+
|
|
16
|
+
const REQUEST_ID_RE = /^[0-9a-f-]{16,64}$/i;
|
|
17
|
+
|
|
18
|
+
function createAskAnswerService({ asks, paste, receipts, onClosure, labelPrefix = 'reply' } = {}) {
|
|
19
|
+
if (!asks || !paste) throw new Error('createAskAnswerService: asks e paste richiesti');
|
|
20
|
+
const emitClosure = typeof onClosure === 'function' ? onClosure : () => {};
|
|
21
|
+
|
|
22
|
+
function pasteText(askId, text, label) {
|
|
23
|
+
return `[${label} reply · ask#${askId}] ${text}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The shared core. Returns {ok:true} or {ok:false, code, error, reason}.
|
|
27
|
+
async function runAnswer({ askId, text, request = null }) {
|
|
28
|
+
// A locked ask refuses new pastes on every path until reconciliation.
|
|
29
|
+
if (receipts && receipts.isBlocked(askId)) {
|
|
30
|
+
return { ok: false, code: 409, error: 'ask con esito incerto: richiede riconciliazione dell\'operatore', reason: 'delivery-unknown-block' };
|
|
31
|
+
}
|
|
32
|
+
// The receipt is authoritative AT THE POINT OF PASTE: an ask whose durable
|
|
33
|
+
// answer state is already terminal cannot produce a second paste, whichever
|
|
34
|
+
// path got here — the ask store alone is not enough, because a reconciliation
|
|
35
|
+
// can land on the receipt without closing the ask.
|
|
36
|
+
const answerState = receipts && typeof receipts.answerStateFor === 'function'
|
|
37
|
+
? receipts.answerStateFor(askId) : null;
|
|
38
|
+
if (answerState === 'committed' || answerState === 'delivered') {
|
|
39
|
+
return { ok: false, code: 409, error: 'ask gia\' consegnato: nessun nuovo paste', reason: 'already-delivered' };
|
|
40
|
+
}
|
|
41
|
+
const claim = asks.claim(askId);
|
|
42
|
+
if (!claim.ok) {
|
|
43
|
+
// Refused BEFORE any paste: the attempt is closed here. Left `pending`, it
|
|
44
|
+
// would come back as delivery-unknown after a restart and block the ask
|
|
45
|
+
// for a paste that never happened.
|
|
46
|
+
if (request && receipts) receipts.finalize(request.peerId, askId, request.requestId, 'failed', `refused:${claim.reason}`);
|
|
47
|
+
if (claim.reason === 'unknown') return { ok: false, code: 404, error: 'ask inesistente', reason: 'unknown' };
|
|
48
|
+
if (claim.reason === 'dismissed') return { ok: false, code: 409, error: 'ask gia\' scartato (dismissed)', reason: 'dismissed' };
|
|
49
|
+
if (claim.reason === 'answering') return { ok: false, code: 409, error: 'risposta gia\' in corso da un\'altra richiesta', reason: 'answering' };
|
|
50
|
+
return { ok: false, code: 409, error: 'ask gia\' risposto', reason: 'answered' };
|
|
51
|
+
}
|
|
52
|
+
let pasted = false;
|
|
53
|
+
try {
|
|
54
|
+
pasted = await paste(claim.ask.session, pasteText(askId, text, labelPrefix));
|
|
55
|
+
} catch (_) { pasted = false; }
|
|
56
|
+
if (!pasted) {
|
|
57
|
+
asks.release(askId); // rollback: the ask stays open and contestable
|
|
58
|
+
if (request && receipts) receipts.finalize(request.peerId, askId, request.requestId, 'failed', 'paste-failed');
|
|
59
|
+
return { ok: false, code: 502, error: `paste fallito: sessione "${claim.ask.session}" non raggiungibile`, reason: 'paste-failed' };
|
|
60
|
+
}
|
|
61
|
+
const committed = asks.commit(askId, text);
|
|
62
|
+
if (request && receipts) receipts.finalize(request.peerId, askId, request.requestId, 'committed', null);
|
|
63
|
+
const finalAsk = asks.get(askId);
|
|
64
|
+
emitClosure('ask-answered', { askId, revision: (finalAsk && finalAsk.revision) || 1, cellSession: finalAsk && finalAsk.session });
|
|
65
|
+
return { ok: true, committed };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// LOCAL answer: validation and binding already happened in the route.
|
|
69
|
+
async function answerLocal({ askId, text }) {
|
|
70
|
+
return runAnswer({ askId, text });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// FEDERATED answer: the receipt lifecycle wraps the same core.
|
|
74
|
+
async function answerFederated({ askId, text, peerId, requestId }) {
|
|
75
|
+
if (!requestId || !REQUEST_ID_RE.test(String(requestId))) {
|
|
76
|
+
return { ok: false, code: 400, error: 'requestId non valido', reason: 'bad-request-id' };
|
|
77
|
+
}
|
|
78
|
+
if (!receipts) return { ok: false, code: 503, error: 'receipt store non disponibile', reason: 'no-receipts' };
|
|
79
|
+
const existing = receipts.get(peerId, askId, requestId);
|
|
80
|
+
const digest = receipts.digestOf(text);
|
|
81
|
+
if (existing) {
|
|
82
|
+
// Same id, same text: the stored state, never a second paste.
|
|
83
|
+
if (existing.digest === digest) {
|
|
84
|
+
return { ok: true, replay: true, state: existing.state };
|
|
85
|
+
}
|
|
86
|
+
// Same id, different text: never answer twice with diverging payloads.
|
|
87
|
+
return { ok: false, code: 409, error: 'stesso requestId con testo diverso', reason: 'request-conflict' };
|
|
88
|
+
}
|
|
89
|
+
// Receipt BEFORE the paste: a failed receipt write means no paste at all.
|
|
90
|
+
const receipt = receipts.attempt({ peerId, askId, requestId, text });
|
|
91
|
+
if (!receipt.ok) {
|
|
92
|
+
if (receipt.reason === 'blocked') {
|
|
93
|
+
return { ok: false, code: 409, error: 'ask con esito incerto: richiede riconciliazione dell\'operatore', reason: 'delivery-unknown-block' };
|
|
94
|
+
}
|
|
95
|
+
if (receipt.reason === 'digest-conflict') {
|
|
96
|
+
return { ok: false, code: 409, error: 'stesso requestId con testo diverso', reason: 'request-conflict' };
|
|
97
|
+
}
|
|
98
|
+
return { ok: false, code: 429, error: 'receipt store al cap', reason: 'receipt-cap' };
|
|
99
|
+
}
|
|
100
|
+
const out = await runAnswer({ askId, text, request: { peerId, requestId } });
|
|
101
|
+
if (out.ok) return { ok: true, state: 'committed' };
|
|
102
|
+
// Every refusal is answered as it is: a rejection BEFORE the paste (claim
|
|
103
|
+
// lost, ask closed, locked) finalizes the attempt as failed, and a transport
|
|
104
|
+
// failure does the same — the peer re-checks the status instead of blindly
|
|
105
|
+
// retrying.
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Dismiss: idempotent, refused while an answer is in flight, and refused on an
|
|
110
|
+
// ask whose delivery is still unknown — dismissing the card would hide an
|
|
111
|
+
// outcome the operator still has to reconcile.
|
|
112
|
+
function dismiss(askId) {
|
|
113
|
+
if (receipts && receipts.isBlocked(askId)) {
|
|
114
|
+
return { ok: false, code: 409, error: 'ask con esito incerto: richiede riconciliazione dell\'operatore', reason: 'delivery-unknown-block' };
|
|
115
|
+
}
|
|
116
|
+
const out = asks.dismiss(askId);
|
|
117
|
+
if (!out.ok) return out;
|
|
118
|
+
emitClosure('ask-dismissed', { askId, revision: (out.ask && out.ask.revision) || 1, cellSession: out.ask && out.ask.session });
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Operator reconciliation of a delivery-unknown attempt. The revision is a
|
|
123
|
+
// MANDATORY compare-and-set: a reconciliation decided on a generation that is
|
|
124
|
+
// already gone must not unlock anything, and the transition itself advances
|
|
125
|
+
// the revision so the same token cannot be replayed.
|
|
126
|
+
function reconcile({ askId, decision, expectedRevision }) {
|
|
127
|
+
if (decision !== 'mark-delivered' && decision !== 'allow-new-attempt') {
|
|
128
|
+
return { ok: false, code: 400, error: 'decision deve essere mark-delivered|allow-new-attempt', reason: 'bad-decision' };
|
|
129
|
+
}
|
|
130
|
+
if (!receipts) return { ok: false, code: 503, error: 'receipt store non disponibile', reason: 'no-receipts' };
|
|
131
|
+
const ask = asks.get(askId);
|
|
132
|
+
if (!ask) return { ok: false, code: 404, error: 'ask inesistente', reason: 'unknown' };
|
|
133
|
+
if (expectedRevision === undefined || expectedRevision === null || expectedRevision === '') {
|
|
134
|
+
return { ok: false, code: 400, error: 'expectedRevision obbligatoria', reason: 'revision-required' };
|
|
135
|
+
}
|
|
136
|
+
if (Number(expectedRevision) !== (ask.revision || 0)) {
|
|
137
|
+
return { ok: false, code: 409, error: 'revision stantia', reason: 'revision-conflict' };
|
|
138
|
+
}
|
|
139
|
+
// A paste that is still in flight is not an outcome to reconcile: its claim
|
|
140
|
+
// is HERS, and deciding here would release a claim mid-paste and let a
|
|
141
|
+
// second paste through. Refused without touching the claim.
|
|
142
|
+
// The window between this check and markReconciled is SYNCHRONOUS (there is
|
|
143
|
+
// no await in between), so no new claim can appear once the refusal is not
|
|
144
|
+
// taken: that is what makes the guard sufficient, not the receipt alone.
|
|
145
|
+
const live = receipts.reconcilableFor(askId);
|
|
146
|
+
if ((typeof asks.isAnswering === 'function' && asks.isAnswering(askId)) || live.live > 0) {
|
|
147
|
+
return {
|
|
148
|
+
ok: false, code: 409, reason: 'answering',
|
|
149
|
+
error: 'paste in corso su questa ask: riconciliazione rifiutata, il claim non si tocca',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
// Nothing unresolved: `ok:true, changed:0` would be a false success.
|
|
153
|
+
if (live.count === 0) {
|
|
154
|
+
return { ok: false, code: 409, reason: 'nothing-to-reconcile', error: 'nessun esito incerto da riconciliare' };
|
|
155
|
+
}
|
|
156
|
+
const expect = { count: live.count, generation: live.generation };
|
|
157
|
+
|
|
158
|
+
// Restrictive write FIRST, permissive one last: the ask-store transition
|
|
159
|
+
// closes the ask (mark-delivered) or just advances the revision
|
|
160
|
+
// (allow-new-attempt), while the receipt keeps blocking the paste until its
|
|
161
|
+
// own durable write lands. A partial failure therefore never leaves a state
|
|
162
|
+
// where a paste is possible: the residue is "ask closed" or "receipt still
|
|
163
|
+
// blocked", never more permissive than the decision.
|
|
164
|
+
const moved = asks.markReconciled(askId, decision);
|
|
165
|
+
if (!moved.ok) {
|
|
166
|
+
return { ok: false, code: 500, error: 'transizione non scritta: stato invariato', reason: 'ask-transition-failed' };
|
|
167
|
+
}
|
|
168
|
+
const out = receipts.reconcile(askId, decision, expect);
|
|
169
|
+
if (!out.ok) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false, code: out.reason === 'nothing-to-reconcile' || out.reason === 'generation-changed' ? 409 : 500,
|
|
172
|
+
reason: out.reason,
|
|
173
|
+
error: decision === 'mark-delivered'
|
|
174
|
+
? 'ricevuta non riconciliata: ask comunque chiuso (nessun paste possibile)'
|
|
175
|
+
: 'ricevuta non riconciliata: blocco ancora attivo (nessun paste possibile)',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return { ok: true, changed: out.changed, revision: (moved.ask && moved.ask.revision) || 0 };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { answerLocal, answerFederated, dismiss, reconcile, pasteText };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { createAskAnswerService, REQUEST_ID_RE };
|