@mmmbuto/nexuscrew 0.8.16 → 0.8.17

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.
@@ -0,0 +1,325 @@
1
+ 'use strict';
2
+ // lib/settings/pairing-coordinator.js — POST /nodes/pair transaction (extracted
3
+ // verbatim from lib/settings/routes.js, behavior-preserving modularization).
4
+ //
5
+ // The HTTP route registration (r.post('/nodes/pair', mutGate, ...)) stays in
6
+ // routes.js; this module owns the staged hydra-join transaction only. Every
7
+ // invariant of the original inline handler is preserved unchanged: bounded
8
+ // readiness probe instead of a fixed sleep, one-time invite consumption (never
9
+ // replayed), exactly-once local/remote rollback on any post-provisioning
10
+ // failure, authenticated federation health before paired:true, and the same
11
+ // token/credential redaction.
12
+ //
13
+ // Hydra join in stadi espliciti: la PWA riceve un contratto strutturato
14
+ // {error, code, stage, detail?, hint?, retryable?} — MAI credenziali, token,
15
+ // header Authorization o contenuto di chiavi nel payload. Stadi distinti:
16
+ // validation | conflict | ssh-start | ssh-ready | join | tunnel-final |
17
+ // confirm | health (+ internal per gli inattesi)
18
+ // Lo sleep fisso 900ms e' sostituito da un probe di readiness bounded
19
+ // (peering.probeTransportReady) eseguito PRIMA di consumare l'invite one-time;
20
+ // una risposta join ambigua (rete morta dopo l'invio) non viene mai rigiocata.
21
+ // Su qualunque fallimento post-provisioning il rollback locale/remoto gira
22
+ // esattamente una volta e lo stage fallito arriva al client.
23
+ //
24
+ // Dipendenze iniettate esplicite (le stesse closure che routes.js teneva):
25
+ // send(res, status, payload) sender JSON con redazione token (condiviso)
26
+ // validName(name) validatore slug (condiviso)
27
+ // defaultDeviceName() etichetta dispositivo proposta (condiviso)
28
+ // nodesPath, configPath, home path risolti (da cfg/deps)
29
+ // seams cfg.settingsSeams (superficie di injection test)
30
+ // runtimePort() accessor della porta app corrente
31
+ const os = require('node:os');
32
+ const crypto = require('node:crypto');
33
+
34
+ const nodesStore = require('../nodes/store.js');
35
+ const nodesCmds = require('../nodes/commands.js');
36
+ const nodesTunnel = require('../nodes/tunnel.js');
37
+ const peering = require('../nodes/peering.js');
38
+ const { probeHealth } = require('../proxy/federation.js');
39
+ const { readRoles } = require('../cli/commands.js');
40
+
41
+ function createPairHandler(deps) {
42
+ const {
43
+ send, validName, defaultDeviceName,
44
+ nodesPath, configPath, home, seams, runtimePort,
45
+ } = deps;
46
+
47
+ return async (req, res) => {
48
+ const b = req.body || {};
49
+ const redactSecrets = (s) => String(s || '')
50
+ .replace(/Bearer\s+\S+/gi, 'Bearer ***')
51
+ .replace(/[A-Za-z0-9_-]{40,}/g, '***');
52
+ const fail = (status, stage, code, detail, extra = {}) => send(res, status, {
53
+ error: redactSecrets(detail), stage, code, detail: redactSecrets(detail), retryable: false, ...extra,
54
+ });
55
+ if (!validName(b.name)) return fail(400, 'validation', 'bad-name', 'name non valido (slug a-z, 0-9, -, max 32)', { retryable: true });
56
+ if (!nodesStore.parseSshTarget(b.ssh)) return fail(400, 'validation', 'bad-ssh', 'alias SSH non valido (atteso user@host o Host alias)', { retryable: true });
57
+ const pair = peering.parsePairingUrl(b.pairingUrl);
58
+ if (!pair) return fail(400, 'validation', 'bad-link', 'link di pairing non valido o corrotto', { retryable: true, hint: 'rigenera il link sul dispositivo che invita' });
59
+ if (b.sshPort !== undefined && !nodesStore.isPort(b.sshPort)) return fail(400, 'validation', 'bad-ssh-port', 'sshPort non valida (1..65535)', { retryable: true });
60
+ if (b.identityFile !== undefined && !nodesStore.isAbsPath(b.identityFile)) return fail(400, 'validation', 'bad-identity-file', 'identityFile non valido (path assoluto)', { retryable: true });
61
+ if (b.label !== undefined && !nodesStore.validLabel(b.label)) return fail(400, 'validation', 'bad-label', 'label non valida (max 64 char, niente a capo)', { retryable: true });
62
+ if (b.localLabel !== undefined && !nodesStore.validLabel(b.localLabel)) return fail(400, 'validation', 'bad-label', 'localLabel non valida (max 64 char, niente a capo)', { retryable: true });
63
+ // label umana del peer come lo vedro' io (display); se assente usa lo slug.
64
+ const peerLabel = nodesStore.sanitizeLabel(b.label, b.name);
65
+ // etichetta umana con cui il peer vedra' questo dispositivo; default sensato.
66
+ const localLabel = nodesStore.sanitizeLabel(b.localLabel, defaultDeviceName());
67
+ const fetchImpl = seams.fetchImpl || fetch;
68
+ const sleep = typeof seams.pairDelay === 'function' ? seams.pairDelay : undefined;
69
+ const transportProbe = typeof seams.probeTransportReady === 'function'
70
+ ? seams.probeTransportReady : peering.probeTransportReady;
71
+ const requestTimeoutMs = Number.isInteger(seams.pairRequestTimeoutMs) && seams.pairRequestTimeoutMs > 0
72
+ ? seams.pairRequestTimeoutMs : 6000;
73
+ // Every protocol request must terminate. Readiness and federation health
74
+ // already have their own bounded probes; join/confirm/cancel use this
75
+ // wrapper so a half-open peer cannot leave the PWA waiting forever.
76
+ const pairFetch = async (url, opts = {}, timeoutMs = requestTimeoutMs) => {
77
+ const ctrl = new AbortController();
78
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
79
+ try { return await fetchImpl(url, { ...opts, signal: ctrl.signal }); }
80
+ finally { clearTimeout(timer); }
81
+ };
82
+
83
+ let provisionalPort = null;
84
+ let portReservation = null;
85
+ let rollbackCredential = null;
86
+ let created = false;
87
+ let rolledBack = false;
88
+ // Rollback locale/remoto ESATTAMENTE una volta, best-effort: cancella la
89
+ // credenziale provvisoria sul peer (se emessa) e rimuove nodo+tunnel locali.
90
+ const rollback = async () => {
91
+ if (rolledBack) return; rolledBack = true;
92
+ if (portReservation) {
93
+ try { await portReservation.release(); } catch (_) { /* best-effort */ }
94
+ portReservation = null;
95
+ }
96
+ if (rollbackCredential && provisionalPort) {
97
+ try {
98
+ await pairFetch(`http://127.0.0.1:${provisionalPort}/pair/cancel`, {
99
+ method: 'POST', headers: { 'content-type': 'application/json' },
100
+ body: JSON.stringify({ instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId, credential: rollbackCredential }),
101
+ }, Math.min(requestTimeoutMs, 3000));
102
+ } catch (_) { /* best-effort */ }
103
+ }
104
+ try {
105
+ const st = nodesStore.loadStore(nodesPath);
106
+ const n = st && nodesStore.getNode(st, b.name);
107
+ if (n && created) {
108
+ nodesTunnel.stopTunnel({ home, name: b.name });
109
+ nodesStore.atomicWriteStore(nodesPath, nodesStore.removeNode(st, b.name));
110
+ }
111
+ } catch (_) { /* best-effort */ }
112
+ };
113
+ const failRolledBack = async (status, stage, code, detail, extra = {}) => {
114
+ await rollback();
115
+ return fail(status, stage, code, detail, extra);
116
+ };
117
+
118
+ try {
119
+ // --- conflict: il nome non deve gia' esistere --------------------------
120
+ let st = nodesStore.loadOrInitStore(nodesPath);
121
+ if (st.nodes.some((n) => n.name === b.name)) {
122
+ return fail(409, 'conflict', 'name-exists', `nodo "${b.name}" gia' presente`, {
123
+ retryable: true, hint: 'scegli un altro nome nelle opzioni avanzate e riprova',
124
+ });
125
+ }
126
+ if (st.nodeId === pair.instanceId) {
127
+ return fail(409, 'conflict', 'self-pairing', 'il link appartiene a questa stessa installazione', {
128
+ hint: 'genera il link sul nodo remoto che vuoi collegare',
129
+ });
130
+ }
131
+ const knownPeer = st.nodes.find((n) => n.nodeId === pair.instanceId);
132
+ if (knownPeer) {
133
+ return fail(409, 'conflict', 'peer-exists', `questa installazione e' gia' collegata come "${knownPeer.name}"`, {
134
+ hint: 'usa il nodo esistente oppure rimuovilo prima di rifare il pairing',
135
+ });
136
+ }
137
+ let localPort;
138
+ try {
139
+ portReservation = await nodesCmds.reserveLocalPort(st, {
140
+ createServerImpl: seams.createPortServer,
141
+ });
142
+ localPort = portReservation.port;
143
+ } catch (e) {
144
+ return fail(502, 'ssh-start', 'local-port-unavailable',
145
+ `nessuna porta locale disponibile per il tunnel: ${String((e && e.message) || e)}`, {
146
+ retryable: true, hint: 'chiudi il processo che occupa le porte locali e riprova',
147
+ });
148
+ }
149
+ provisionalPort = localPort;
150
+ const acceptToken = crypto.randomBytes(32).toString('base64url');
151
+ try {
152
+ st = nodesStore.addNode(st, {
153
+ name: b.name, ssh: b.ssh, sshPort: b.sshPort,
154
+ remotePort: pair.port, localPort, identityFile: b.identityFile,
155
+ transport: 'auto', autostart: true, visibility: 'network',
156
+ direction: 'outbound', acceptToken, label: peerLabel,
157
+ });
158
+ } catch (e) {
159
+ const msg = String((e && e.message) || e);
160
+ const isDup = msg.includes('duplicato') || msg.includes('self-reference');
161
+ return failRolledBack(isDup ? 409 : 400, isDup ? 'conflict' : 'validation', 'node-rejected', msg, { retryable: !isDup });
162
+ }
163
+ nodesStore.atomicWriteStore(nodesPath, st);
164
+ created = true;
165
+ const node = nodesStore.getNode(st, b.name);
166
+
167
+ // --- ssh-start: supervisor del tunnel -L provvisorio --------------------
168
+ // Il bind OS-aware ha protetto la scelta fino a questo punto. SSH deve
169
+ // prendere la stessa porta, quindi rilasciamo immediatamente prima dello
170
+ // spawn (eventuali race residue emergono come local-forward-bind).
171
+ await portReservation.release();
172
+ portReservation = null;
173
+ const started = nodesTunnel.startForward({
174
+ home, node, localAppPort: runtimePort(),
175
+ spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
176
+ sshBin: seams.sshBin, logFd: seams.logFd,
177
+ });
178
+ if (!started.started && started.reason !== 'already running') {
179
+ return failRolledBack(502, 'ssh-start', 'tunnel-start-failed', started.reason || 'avvio del tunnel SSH fallito', {
180
+ retryable: true,
181
+ hint: 'verifica ssh e target/alias su questo dispositivo; il link NON e\' stato consumato e puoi riprovare',
182
+ });
183
+ }
184
+
185
+ // --- ssh-ready: readiness bounded PRIMA di consumare l'invite -----------
186
+ const ready = await transportProbe({
187
+ port: localPort, capability: pair.invite, expectedInstanceId: pair.instanceId,
188
+ fetchImpl, sleep,
189
+ });
190
+ if (!ready.ready) {
191
+ const diagnosis = (seams.readTunnelDiagnostic || nodesTunnel.readTunnelDiagnostic)(home, b.name, pair.port);
192
+ return failRolledBack(502, 'ssh-ready', (diagnosis && diagnosis.code) || ready.code || 'transport-not-ready',
193
+ (diagnosis && diagnosis.detail)
194
+ || `il peer non risponde attraverso il tunnel SSH (${ready.attempts} tentativi${ready.lastError ? `: ${ready.lastError}` : ''})`, {
195
+ retryable: true,
196
+ hint: (diagnosis && diagnosis.hint)
197
+ || 'controlla target SSH, porta e chiavi; il link NON e\' stato consumato, puoi riprovare',
198
+ });
199
+ }
200
+
201
+ // --- join: consuma l'invite one-time (UNA volta, mai replay) ------------
202
+ let jr;
203
+ try {
204
+ jr = await pairFetch(`http://127.0.0.1:${localPort}/pair/join`, {
205
+ method: 'POST', headers: { 'content-type': 'application/json' },
206
+ body: JSON.stringify({
207
+ invite: pair.invite,
208
+ instanceId: st.nodeId,
209
+ name: String(b.localName || os.hostname()).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'node',
210
+ label: localLabel,
211
+ port: runtimePort(),
212
+ acceptToken,
213
+ // Pairing establishes a private client-to-hub connection. Sharing
214
+ // this device back through the hub is a separate explicit action.
215
+ shared: false,
216
+ roles: readRoles(configPath),
217
+ }),
218
+ });
219
+ } catch (e) {
220
+ // Risposta persa DOPO l'invio: l'invite potrebbe essere stato consumato.
221
+ // Un join ambiguo non si rigioca mai.
222
+ return failRolledBack(502, 'join', 'join-ambiguous',
223
+ 'risposta di join persa: l\'invito potrebbe essere gia\' stato consumato', {
224
+ hint: 'rigenera un nuovo link sul dispositivo che invita e riprova',
225
+ });
226
+ }
227
+ const joined = await jr.json().catch(() => ({}));
228
+ if (jr.status === 410) {
229
+ return failRolledBack(502, 'join', 'invite-expired', 'invito scaduto o gia\' usato (one-time)', {
230
+ hint: 'rigenera un nuovo link sul dispositivo che invita',
231
+ });
232
+ }
233
+ if (!jr.ok) {
234
+ return failRolledBack(502, 'join', 'join-rejected', joined.error || `join rifiutato dal peer (HTTP ${jr.status})`, {
235
+ hint: 'rigenera un nuovo link e riprova',
236
+ });
237
+ }
238
+ const joinedRoles = joined.roles === undefined ? null : nodesStore.parseRoles(joined.roles);
239
+ if (!nodesStore.validToken(joined.credential) || !nodesStore.isPort(joined.reversePort)
240
+ || !nodesStore.NODE_ID_RE.test(joined.instanceId) || (joined.roles !== undefined && !joinedRoles)) {
241
+ return failRolledBack(502, 'join', 'join-invalid-response', 'risposta di join non valida dal peer', {
242
+ hint: 'versioni NexusCrew incompatibili? aggiorna entrambi i nodi',
243
+ });
244
+ }
245
+ rollbackCredential = joined.credential;
246
+ if (joined.instanceId !== pair.instanceId) {
247
+ return failRolledBack(502, 'join', 'peer-identity-mismatch',
248
+ 'l\'identita\' del peer raggiunto non coincide con quella contenuta nel link', {
249
+ hint: 'controlla che il target SSH punti al nodo che ha generato il link',
250
+ });
251
+ }
252
+
253
+ // --- tunnel-final: connessione privata, solo -L -------------------------
254
+ // reversePort resta negoziata per un futuro Share opt-in, ma il builder
255
+ // non emette -R finche' shared non diventa true.
256
+ st = nodesStore.loadOrInitStore(nodesPath);
257
+ st = nodesStore.updateNode(st, b.name, {
258
+ token: joined.credential, nodeId: joined.instanceId, reversePort: joined.reversePort,
259
+ shared: false,
260
+ ...(joinedRoles ? { roles: joinedRoles, rolesKnown: true } : {}),
261
+ });
262
+ nodesStore.atomicWriteStore(nodesPath, st);
263
+ nodesTunnel.stopTunnel({ home, name: b.name });
264
+ const finalStart = nodesTunnel.startForward({
265
+ home, node: nodesStore.getNode(st, b.name), localAppPort: runtimePort(),
266
+ spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
267
+ sshBin: seams.sshBin, logFd: seams.logFd,
268
+ });
269
+ if (!finalStart.started && finalStart.reason !== 'already running') {
270
+ return failRolledBack(502, 'tunnel-final', 'tunnel-restart-failed', finalStart.reason || 'riavvio del tunnel negoziato fallito', {});
271
+ }
272
+ const readyFinal = await transportProbe({
273
+ port: localPort, capability: joined.credential, expectedInstanceId: joined.instanceId,
274
+ fetchImpl, sleep,
275
+ });
276
+ if (!readyFinal.ready) {
277
+ const diagnosis = (seams.readTunnelDiagnostic || nodesTunnel.readTunnelDiagnostic)(home, b.name, pair.port);
278
+ return failRolledBack(502, 'tunnel-final', (diagnosis && diagnosis.code) || readyFinal.code || 'transport-not-ready',
279
+ (diagnosis && diagnosis.detail)
280
+ || `il tunnel negoziato non risponde o non corrisponde al peer atteso (${readyFinal.attempts} tentativi)`, {
281
+ hint: (diagnosis && diagnosis.hint) || 'verifica il target SSH e riprova con un nuovo link',
282
+ });
283
+ }
284
+
285
+ // --- confirm: idempotente lato peer -> bounded retry ---------------------
286
+ let confirmed = null;
287
+ for (let attempt = 0; attempt < 3; attempt += 1) {
288
+ try {
289
+ confirmed = await pairFetch(`http://127.0.0.1:${localPort}/pair/confirm`, {
290
+ method: 'POST', headers: { 'content-type': 'application/json' },
291
+ body: JSON.stringify({ credential: joined.credential }),
292
+ }, Math.min(requestTimeoutMs, 3500));
293
+ if (confirmed.ok) break;
294
+ } catch (_) { /* retry: confirm e' idempotente lato peer */ }
295
+ if (attempt < 2) await (sleep ? sleep() : new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1))));
296
+ }
297
+ if (!confirmed || !confirmed.ok) {
298
+ const x = confirmed ? await confirmed.json().catch(() => ({})) : {};
299
+ return failRolledBack(502, 'confirm', 'confirm-failed',
300
+ x.error || `conferma pairing fallita (HTTP ${confirmed ? confirmed.status : 'irraggiungibile'})`, {
301
+ hint: 'rigenera un nuovo link e riprova',
302
+ });
303
+ }
304
+
305
+ // --- health: federazione AUTENTICATA verificata prima di paired:true ----
306
+ const health = await probeHealth({
307
+ port: localPort, token: joined.credential, expectedInstanceId: joined.instanceId,
308
+ fetchImpl, now: Date.now(),
309
+ });
310
+ if (!health || health.status !== 'healthy') {
311
+ return failRolledBack(502, 'health', 'federation-health-failed',
312
+ (health && health.detail) || 'health federato non verificabile dopo la conferma', {
313
+ hint: 'pairing annullato e ripulito: rigenera il link e riprova',
314
+ });
315
+ }
316
+
317
+ send(res, 200, { paired: true, name: b.name, instanceId: joined.instanceId, transport: 'auto', health: { status: health.status } });
318
+ } catch (e) {
319
+ await rollback();
320
+ fail(502, 'internal', 'unexpected', String((e && e.message) || e), {});
321
+ }
322
+ };
323
+ }
324
+
325
+ module.exports = { createPairHandler };
@@ -0,0 +1,126 @@
1
+ 'use strict';
2
+ // lib/settings/public-peering-routes.js — public peering surface (the one-time
3
+ // invite itself is the capability). Extracted verbatim from
4
+ // lib/settings/routes.js (behavior-preserving modularization); routes.js
5
+ // re-exports publicPeeringRoutes for backward compatibility.
6
+ //
7
+ // The route exposes no generic API and creates a scoped peer credential, never
8
+ // a UI token. Identity proof does not consume the capability and never receives
9
+ // invite/token in clear text: it prevents any HTTP listener on the -L port from
10
+ // being mistaken for the node contained in the link.
11
+ const os = require('node:os');
12
+ const express = require('express');
13
+
14
+ const nodesStore = require('../nodes/store.js');
15
+ const peering = require('../nodes/peering.js');
16
+ const { readRoles } = require('../cli/commands.js');
17
+ const { configJsonPath } = require('../config.js');
18
+
19
+ function validPeerName(name) { return typeof name === 'string' && nodesStore.NODE_NAME_RE.test(name); }
20
+
21
+ function publicPeeringRoutes(deps = {}) {
22
+ const cfg = deps.cfg || {};
23
+ const home = cfg.home || os.homedir();
24
+ const configPath = cfg.configPath || configJsonPath();
25
+ const nodesPath = deps.nodesPath || cfg.nodesPath || nodesStore.defaultNodesPath(home);
26
+ const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
27
+ const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
28
+ const r = express.Router();
29
+ const attempts = new Map();
30
+ r.use(express.json({ limit: '8kb' }));
31
+ // Identity proof non consuma la capability e non riceve mai invite/token in
32
+ // chiaro. Serve a impedire che un qualunque listener HTTP sulla porta -L
33
+ // venga scambiato per il nodo contenuto nel link.
34
+ r.post('/identity', (req, res) => {
35
+ const key = `identity:${String(req.socket && req.socket.remoteAddress || 'local')}`;
36
+ const now = Date.now();
37
+ const recent = (attempts.get(key) || []).filter((x) => now - x < 60_000);
38
+ recent.push(now); attempts.set(key, recent);
39
+ if (recent.length > 30) return res.status(429).json({ error: 'troppi tentativi' });
40
+ const b = req.body || {};
41
+ const proof = peering.capabilityIdentity({
42
+ invitesPath, pendingPath, capabilityId: b.capabilityId, challenge: b.challenge, now,
43
+ });
44
+ if (!proof) return res.status(404).json({ error: 'capability non valida' });
45
+ const st = nodesStore.loadStore(nodesPath);
46
+ if (!st || !nodesStore.NODE_ID_RE.test(st.nodeId)) return res.status(503).json({ error: 'identita nodo non disponibile' });
47
+ return res.json({ ok: true, instanceId: st.nodeId, proof });
48
+ });
49
+ r.post('/join', (req, res) => {
50
+ const key = `join:${String(req.socket && req.socket.remoteAddress || 'local')}`;
51
+ const now = Date.now();
52
+ const recent = (attempts.get(key) || []).filter((x) => now - x < 60_000);
53
+ recent.push(now); attempts.set(key, recent);
54
+ if (recent.length > 10) return res.status(429).json({ error: 'troppi tentativi di pairing' });
55
+ const b = req.body || {};
56
+ const peerRoles = b.roles === undefined ? null : nodesStore.parseRoles(b.roles);
57
+ if (!nodesStore.validToken(b.invite) || !nodesStore.NODE_ID_RE.test(b.instanceId)
58
+ || !validPeerName(b.name) || !nodesStore.isPort(b.port) || !nodesStore.validToken(b.acceptToken)
59
+ || (b.roles !== undefined && !peerRoles)
60
+ // Pairing is always private. Publishing is a separate authenticated
61
+ // action after the reverse channel is live and health-checked.
62
+ || (b.shared !== undefined && b.shared !== false)) {
63
+ return res.status(400).json({ error: 'pairing request non valida' });
64
+ }
65
+ if (b.label !== undefined && !nodesStore.validLabel(b.label)) {
66
+ return res.status(400).json({ error: 'label non valida' });
67
+ }
68
+ if (!peering.consumeInvite({ invitesPath, invite: b.invite })) return res.status(410).json({ error: 'invito scaduto o gia usato' });
69
+ try {
70
+ const st = nodesStore.loadOrInitStore(nodesPath);
71
+ if (st.nodeId === b.instanceId || st.nodes.some((n) => n.nodeId === b.instanceId)) return res.status(409).json({ error: 'peer duplicato' });
72
+ let name = b.name;
73
+ for (let i = 2; nodesStore.getNode(st, name); i += 1) name = `${b.name.slice(0, 28)}-${i}`;
74
+ const reversePort = peering.allocateReversePort(st.nodes);
75
+ const credential = peering.createPending({ pendingPath, data: {
76
+ name, remotePort: b.port, reversePort, instanceId: b.instanceId, acceptToken: b.acceptToken,
77
+ shared: false,
78
+ label: nodesStore.sanitizeLabel(b.label, name),
79
+ ...(peerRoles ? { roles: { ...peerRoles, node: false }, rolesKnown: true } : { rolesKnown: false }),
80
+ } });
81
+ res.json({ paired: true, instanceId: st.nodeId, reversePort, credential, roles: readRoles(configPath) });
82
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
83
+ });
84
+ r.post('/confirm', (req, res) => {
85
+ const b = req.body || {};
86
+ if (!nodesStore.validToken(b.credential)) return res.status(400).json({ error: 'confirm non valido' });
87
+ const pending = peering.consumePending({ pendingPath, credential: b.credential });
88
+ if (!pending) {
89
+ const st = nodesStore.loadStore(nodesPath);
90
+ if (st && st.nodes.some((n) => n.acceptToken && peering.safeEqual(n.acceptToken, b.credential))) return res.json({ confirmed: true, idempotent: true });
91
+ return res.status(410).json({ error: 'pairing pending scaduto o gia usato' });
92
+ }
93
+ try {
94
+ let st = nodesStore.loadOrInitStore(nodesPath);
95
+ if (st.nodeId === pending.instanceId || st.nodes.some((n) => n.nodeId === pending.instanceId)) return res.status(409).json({ error: 'peer duplicato' });
96
+ st = nodesStore.addNode(st, {
97
+ name: pending.name, remotePort: pending.remotePort, localPort: pending.reversePort,
98
+ direction: 'inbound', transport: 'inbound', autostart: true,
99
+ visibility: 'network', shared: pending.shared === true, nodeId: pending.instanceId,
100
+ token: pending.acceptToken, acceptToken: b.credential,
101
+ ...(pending.roles ? { roles: pending.roles } : {}),
102
+ rolesKnown: pending.rolesKnown === true,
103
+ ...(pending.label ? { label: pending.label } : {}),
104
+ });
105
+ nodesStore.atomicWriteStore(nodesPath, st);
106
+ res.json({ confirmed: true });
107
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
108
+ });
109
+ r.post('/cancel', (req, res) => {
110
+ const b = req.body || {};
111
+ if (!nodesStore.NODE_ID_RE.test(b.instanceId) || !nodesStore.validToken(b.credential)) return res.status(400).json({ error: 'cancel non valido' });
112
+ try {
113
+ const pending = peering.consumePending({ pendingPath, credential: b.credential });
114
+ if (!pending || pending.instanceId !== b.instanceId) {
115
+ const st = nodesStore.loadStore(nodesPath);
116
+ const peer = st && st.nodes.find((n) => n.nodeId === b.instanceId && n.acceptToken && peering.safeEqual(n.acceptToken, b.credential));
117
+ if (!peer) return res.status(404).json({ error: 'pair non trovato' });
118
+ nodesStore.atomicWriteStore(nodesPath, nodesStore.removeNode(st, peer.name));
119
+ }
120
+ res.json({ cancelled: true });
121
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
122
+ });
123
+ return r;
124
+ }
125
+
126
+ module.exports = { publicPeeringRoutes };