@mmmbuto/nexuscrew 0.7.7 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +153 -25
- package/bin/nexuscrew.js +10 -4
- package/frontend/dist/assets/index-COsoJxXQ.css +32 -0
- package/frontend/dist/assets/index-Dey9rdY-.js +90 -0
- package/frontend/dist/assets/qr-scanner-worker.min-D85Z9gVD.js +98 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +29 -0
- package/frontend/dist/version.json +1 -0
- package/lib/auth/middleware.js +7 -1
- package/lib/auth/token.js +31 -1
- package/lib/cli/commands.js +502 -43
- package/lib/cli/doctor.js +160 -0
- package/lib/cli/fleet-service.js +16 -3
- package/lib/cli/init.js +69 -11
- package/lib/cli/path.js +24 -0
- package/lib/cli/service.js +14 -8
- package/lib/cli/url.js +63 -0
- package/lib/config.js +5 -0
- package/lib/decks/routes.js +81 -0
- package/lib/decks/store.js +123 -0
- package/lib/files/routes.js +57 -1
- package/lib/files/store.js +16 -1
- package/lib/fleet/builtin.js +151 -24
- package/lib/fleet/definitions.js +26 -0
- package/lib/fleet/managed.js +381 -0
- package/lib/fleet/routes.js +5 -4
- package/lib/mcp/server.js +381 -0
- package/lib/nodes/commands.js +411 -0
- package/lib/nodes/peering.js +116 -0
- package/lib/nodes/store.js +425 -0
- package/lib/nodes/tunnel-supervisor.js +102 -0
- package/lib/nodes/tunnel.js +315 -0
- package/lib/notify/asks.js +150 -0
- package/lib/notify/events.js +59 -0
- package/lib/notify/notifier.js +37 -0
- package/lib/notify/persist.js +73 -0
- package/lib/notify/push.js +289 -0
- package/lib/notify/routes.js +260 -0
- package/lib/proxy/federation.js +217 -0
- package/lib/proxy/node-proxy.js +305 -0
- package/lib/pty/attach.js +34 -9
- package/lib/pty/provider.js +21 -6
- package/lib/server.js +291 -14
- package/lib/settings/routes.js +685 -0
- package/lib/ws/bridge.js +10 -1
- package/package.json +8 -2
- package/skills/nexuscrew-agent/SKILL.md +83 -0
- package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
- package/skills/nexuscrew-agent/bin/nc-send +48 -0
- package/frontend/dist/assets/index-DUbtTZMj.css +0 -32
- package/frontend/dist/assets/index-EVa9bnAC.js +0 -81
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/settings/routes.js — Settings API per wizard/settings UI (design §4b(6), B2).
|
|
3
|
+
//
|
|
4
|
+
// Contratto duro:
|
|
5
|
+
// - read-only: GET /api/settings (roles, firstRun, port, platform, service,
|
|
6
|
+
// rendezvous redatto, version).
|
|
7
|
+
// - mutanti (LISTA CHIUSA §4b(6)), tutti dietro requireToken (montati sotto il
|
|
8
|
+
// router /api gia' autenticato) + gate READONLY route-level (pattern
|
|
9
|
+
// lib/fleet/routes.js, 403 esplicito):
|
|
10
|
+
// POST /config scrittura config.json ATOMICA (whitelist chiavi)
|
|
11
|
+
// POST /token/rotate riusa rotateToken(); token MAI in risposta (§4b(3))
|
|
12
|
+
// POST /nodes nodes add (riusa lib/nodes/commands.js)
|
|
13
|
+
// DELETE /nodes/:name nodes remove
|
|
14
|
+
// POST /node-role node on/off (rendezvous config)
|
|
15
|
+
// POST /service/regenerate rigenera unit service (NO restart automatico)
|
|
16
|
+
// - NON gate READONLY (lifecycle di PROCESSO, non mutazione di config — decisione
|
|
17
|
+
// B0 documentata in lib/nodes/commands.js: READONLY blocca le mutazioni di
|
|
18
|
+
// config, non up/down/restart dei tunnel):
|
|
19
|
+
// POST /nodes/:name/test non-mutante (POST per coerenza azione)
|
|
20
|
+
// POST /nodes/:name/up|down|restart
|
|
21
|
+
//
|
|
22
|
+
// Invarianti:
|
|
23
|
+
// - token MAI in risposta: ogni payload passa da deepScrubTokens() (cintura oltre
|
|
24
|
+
// alla redazione a monte via redactStore); rotateToken() ritorna il nuovo token
|
|
25
|
+
// ma il valore viene SCARTATO, mai serializzato.
|
|
26
|
+
// - failure esplicite: {error:'causa precisa'} con status coerente (400 input,
|
|
27
|
+
// 403 readonly, 404 nodo, 409 conflitto, 500 con messaggio). Niente stack trace.
|
|
28
|
+
// - validazione input strict fail-closed: schema chiuso per ogni body, garbage
|
|
29
|
+
// -> 400 con causa, mai guess.
|
|
30
|
+
// - NIENTE reimplementazione della logica B0: i mutanti nodes/node-role
|
|
31
|
+
// incapsulano le funzioni CLI di lib/nodes/commands.js (log catturato per
|
|
32
|
+
// estrarre esito/authorized_keys); config.json scritto col pattern atomico
|
|
33
|
+
// tmp+rename di lib/nodes/store.js.
|
|
34
|
+
//
|
|
35
|
+
// Nota lifecycle: i tunnel avviati da /nodes/:name/up sono spawn DETACHED+unref
|
|
36
|
+
// con pidfile (lib/nodes/tunnel.js): non restano figli dell'handler HTTP; il
|
|
37
|
+
// commento B0 "mai spawn dalla superficie web" e' superato per B2 da questo
|
|
38
|
+
// contratto esplicito (§4b(6): "restart processi tunnel" e' mutazione ammessa).
|
|
39
|
+
const fs = require('node:fs');
|
|
40
|
+
const os = require('node:os');
|
|
41
|
+
const path = require('node:path');
|
|
42
|
+
const crypto = require('node:crypto');
|
|
43
|
+
const express = require('express');
|
|
44
|
+
|
|
45
|
+
const nodesStore = require('../nodes/store.js');
|
|
46
|
+
const nodesCmds = require('../nodes/commands.js');
|
|
47
|
+
const nodesTunnel = require('../nodes/tunnel.js');
|
|
48
|
+
const peering = require('../nodes/peering.js');
|
|
49
|
+
const { rotateToken } = require('../auth/token.js');
|
|
50
|
+
const { generateService, installService, installPath: svcInstallPath } = require('../cli/service.js');
|
|
51
|
+
const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js');
|
|
52
|
+
const { isServiceRunning, readRoles } = require('../cli/commands.js');
|
|
53
|
+
const { configJsonPath } = require('../config.js');
|
|
54
|
+
const VERSION = require('../../package.json').version;
|
|
55
|
+
|
|
56
|
+
// Whitelist chiavi scrivibili di config.json via API (lista chiusa dal task B2).
|
|
57
|
+
const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone']);
|
|
58
|
+
const ROLE_KEYS = new Set(['client', 'node']);
|
|
59
|
+
const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath']);
|
|
60
|
+
const NODE_ROLE_KEYS = new Set(['enabled', 'rendezvousSsh', 'publishedPort', 'keyPath']);
|
|
61
|
+
|
|
62
|
+
// Cintura §4b(3): rimozione ricorsiva di ogni chiave `token` da QUALUNQUE payload
|
|
63
|
+
// di risposta (la vista redatta espone hasToken, mai il valore). Difesa in
|
|
64
|
+
// profondita' oltre a redactStore: anche un bug a monte non fa uscire il segreto.
|
|
65
|
+
function deepScrubTokens(v) {
|
|
66
|
+
if (Array.isArray(v)) return v.map(deepScrubTokens);
|
|
67
|
+
if (v && typeof v === 'object') {
|
|
68
|
+
const out = {};
|
|
69
|
+
for (const [k, val] of Object.entries(v)) {
|
|
70
|
+
if (k === 'token') continue;
|
|
71
|
+
out[k] = deepScrubTokens(val);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
return v;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Cattura il log delle funzioni CLI riusate: serve per estrarre la causa d'errore
|
|
79
|
+
// precisa e la riga authorized_keys (che NON e' un segreto: pubkey con restrict).
|
|
80
|
+
function capture() {
|
|
81
|
+
const lines = [];
|
|
82
|
+
return { lines, log: (s) => lines.push(String(s)) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function lastLine(cap, fallback) {
|
|
86
|
+
return cap.lines.length ? cap.lines[cap.lines.length - 1] : fallback;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function authorizedKeysLine(cap) {
|
|
90
|
+
return cap.lines.find((l) => l.startsWith('restrict,')) || null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Scrittura ATOMICA di config.json: tmp stessa dir (suffisso random: due write
|
|
94
|
+
// concorrenti nello stesso processo non collidono) -> chmod 0600 -> rename.
|
|
95
|
+
// Stesso pattern hardening di lib/nodes/store.js (no-symlink incluso).
|
|
96
|
+
function atomicWriteConfig(p, obj) {
|
|
97
|
+
try {
|
|
98
|
+
if (fs.lstatSync(p).isSymbolicLink()) {
|
|
99
|
+
throw new Error('refuse to write: config.json target e\' un symlink');
|
|
100
|
+
}
|
|
101
|
+
} catch (e) {
|
|
102
|
+
if (e.code !== 'ENOENT') throw e;
|
|
103
|
+
}
|
|
104
|
+
const dir = path.dirname(p);
|
|
105
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
106
|
+
const tmp = path.join(dir, `.${path.basename(p)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
107
|
+
try {
|
|
108
|
+
fs.writeFileSync(tmp, `${JSON.stringify(obj, null, 2)}\n`, { mode: 0o600 });
|
|
109
|
+
fs.chmodSync(tmp, 0o600);
|
|
110
|
+
fs.renameSync(tmp, p);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
try { fs.unlinkSync(tmp); } catch (_) { /* cleanup best-effort */ }
|
|
113
|
+
throw e;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// config.json corrente come oggetto ({} se assente/illeggibile) + flag esistenza.
|
|
118
|
+
function readConfigFile(p) {
|
|
119
|
+
try {
|
|
120
|
+
const obj = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
121
|
+
return { exists: true, cfg: (obj && typeof obj === 'object' && !Array.isArray(obj)) ? obj : {} };
|
|
122
|
+
} catch (_) { return { exists: false, cfg: {} }; }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function settingsRoutes(deps = {}) {
|
|
126
|
+
const cfg = deps.cfg || {};
|
|
127
|
+
const seams = cfg.settingsSeams || {};
|
|
128
|
+
// tokenStore/closeSessions iniettati da server.js per la semantica di invalidazione
|
|
129
|
+
// live (audit F7 / §4b(3)). Assenti nei test unitari puri su routes -> la rotazione
|
|
130
|
+
// scrive solo il file (come prima), senza reload in-memory.
|
|
131
|
+
const tokenStore = deps.tokenStore || null;
|
|
132
|
+
const closeSessions = deps.closeSessions || null;
|
|
133
|
+
const home = cfg.home || os.homedir();
|
|
134
|
+
const configDir = cfg.configDir || path.join(home, '.nexuscrew');
|
|
135
|
+
// configJsonPath() rispetta NEXUSCREW_CONFIG_FILE (stessa risoluzione di
|
|
136
|
+
// loadConfig): settings API e server DEVONO leggere/scrivere lo STESSO file —
|
|
137
|
+
// il fallback home-based divergeva dalla config del server nelle istanze
|
|
138
|
+
// isolate via env (bug trovato in audit: smoke test che scrivono la config reale).
|
|
139
|
+
const configPath = cfg.configPath || configJsonPath();
|
|
140
|
+
const nodesPath = deps.nodesPath || cfg.nodesPath || nodesStore.defaultNodesPath(home);
|
|
141
|
+
const tokenPath = cfg.tokenPath || path.join(configDir, 'token');
|
|
142
|
+
const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
|
|
143
|
+
const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
|
|
144
|
+
const platform = seams.platform || detectPlatform();
|
|
145
|
+
|
|
146
|
+
const r = express.Router();
|
|
147
|
+
r.use(express.json({ limit: '8kb' }));
|
|
148
|
+
|
|
149
|
+
const send = (res, status, payload) => res.status(status).json(deepScrubTokens(payload));
|
|
150
|
+
|
|
151
|
+
// Gate READONLY route-level (pattern lib/fleet/routes.js): PRIMA di qualunque
|
|
152
|
+
// dispatch verso le funzioni CLI. Vale per i soli mutanti di config/token/service.
|
|
153
|
+
const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
154
|
+
const mutGate = (_req, res, next) => {
|
|
155
|
+
if (readonly()) return send(res, 403, { error: 'READONLY: mutazione settings bloccata' });
|
|
156
|
+
next();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Opzioni comuni passate alle funzioni CLI riusate (path espliciti + seam test).
|
|
160
|
+
const cliOpts = (cap) => ({
|
|
161
|
+
home, configDir, configPath, nodesPath, log: cap.log,
|
|
162
|
+
keygen: seams.keygen, execFileImpl: seams.execFileImpl,
|
|
163
|
+
spawnImpl: seams.spawnImpl, sshBin: seams.sshBin, logFd: seams.logFd,
|
|
164
|
+
httpProbe: seams.httpProbe, sshVersion: seams.sshVersion,
|
|
165
|
+
spawnSyncImpl: seams.spawnSyncImpl,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const validName = (name) => typeof name === 'string' && nodesStore.NODE_NAME_RE.test(name);
|
|
169
|
+
|
|
170
|
+
// --- GET / — vista read-only per wizard/settings UI -----------------------
|
|
171
|
+
r.get('/', (_req, res) => {
|
|
172
|
+
try {
|
|
173
|
+
const { exists, cfg: fileCfg } = readConfigFile(configPath);
|
|
174
|
+
// firstRun: config.json assente O wizardDone non esplicitamente true
|
|
175
|
+
// (il wizard persiste wizardDone:true via POST /config a fine setup).
|
|
176
|
+
const firstRun = !exists || fileCfg.wizardDone !== true;
|
|
177
|
+
const svcPath = seams.serviceInstallPath || svcInstallPath(platform, home);
|
|
178
|
+
const service = {
|
|
179
|
+
installed: fs.existsSync(svcPath),
|
|
180
|
+
active: isServiceRunning({ platform, execImpl: seams.execImpl, uid: seams.uid, home }),
|
|
181
|
+
};
|
|
182
|
+
const out = {
|
|
183
|
+
roles: readRoles(configPath),
|
|
184
|
+
firstRun,
|
|
185
|
+
port: cfg.port,
|
|
186
|
+
platform,
|
|
187
|
+
service,
|
|
188
|
+
version: VERSION,
|
|
189
|
+
};
|
|
190
|
+
// rendezvous via redactStore (view sicura §4b(4)): non contiene token, ma
|
|
191
|
+
// la si prende comunque SOLO dalla vista redatta.
|
|
192
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
193
|
+
if (st) {
|
|
194
|
+
const view = nodesStore.redactStore(st);
|
|
195
|
+
if (view.rendezvous) out.rendezvous = view.rendezvous;
|
|
196
|
+
}
|
|
197
|
+
send(res, 200, out);
|
|
198
|
+
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// --- POST /config — scrittura atomica, subset whitelisted ------------------
|
|
202
|
+
r.post('/config', mutGate, (req, res) => {
|
|
203
|
+
try {
|
|
204
|
+
const b = req.body;
|
|
205
|
+
if (!b || typeof b !== 'object' || Array.isArray(b)) {
|
|
206
|
+
return send(res, 400, { error: 'body deve essere un oggetto JSON' });
|
|
207
|
+
}
|
|
208
|
+
for (const k of Object.keys(b)) {
|
|
209
|
+
if (!CONFIG_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (whitelist: roles, port, wizardDone)` });
|
|
210
|
+
}
|
|
211
|
+
if (Object.keys(b).length === 0) {
|
|
212
|
+
return send(res, 400, { error: 'nessuna chiave da scrivere (whitelist: roles, port, wizardDone)' });
|
|
213
|
+
}
|
|
214
|
+
if (b.roles !== undefined) {
|
|
215
|
+
if (!b.roles || typeof b.roles !== 'object' || Array.isArray(b.roles)) {
|
|
216
|
+
return send(res, 400, { error: 'roles deve essere un oggetto {client?, node?}' });
|
|
217
|
+
}
|
|
218
|
+
for (const k of Object.keys(b.roles)) {
|
|
219
|
+
if (!ROLE_KEYS.has(k)) return send(res, 400, { error: `roles: chiave non ammessa "${k}" (solo client, node)` });
|
|
220
|
+
if (typeof b.roles[k] !== 'boolean') return send(res, 400, { error: `roles.${k} deve essere boolean` });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (b.port !== undefined && !nodesStore.isPort(b.port)) {
|
|
224
|
+
return send(res, 400, { error: 'port deve essere un intero 1..65535' });
|
|
225
|
+
}
|
|
226
|
+
if (b.wizardDone !== undefined && typeof b.wizardDone !== 'boolean') {
|
|
227
|
+
return send(res, 400, { error: 'wizardDone deve essere boolean' });
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// merge sul config esistente (preserva le chiavi non gestite qui)
|
|
231
|
+
const { cfg: current } = readConfigFile(configPath);
|
|
232
|
+
const next = { ...current };
|
|
233
|
+
if (b.roles !== undefined) {
|
|
234
|
+
const prev = (current.roles && typeof current.roles === 'object') ? current.roles : {};
|
|
235
|
+
next.roles = { client: !!prev.client, node: !!prev.node, ...b.roles };
|
|
236
|
+
}
|
|
237
|
+
if (b.port !== undefined) next.port = b.port;
|
|
238
|
+
if (b.wizardDone !== undefined) next.wizardDone = b.wizardDone;
|
|
239
|
+
atomicWriteConfig(configPath, next);
|
|
240
|
+
|
|
241
|
+
const out = {
|
|
242
|
+
saved: true,
|
|
243
|
+
config: { roles: next.roles, port: next.port, wizardDone: next.wizardDone },
|
|
244
|
+
};
|
|
245
|
+
// Il server legge la porta SOLO allo startup: il cambio vale al prossimo
|
|
246
|
+
// restart (contratto dichiarato nella risposta, la UI avvisa).
|
|
247
|
+
if (b.port !== undefined && b.port !== cfg.port) {
|
|
248
|
+
out.note = 'la porta cambia al prossimo restart del service';
|
|
249
|
+
}
|
|
250
|
+
send(res, 200, out);
|
|
251
|
+
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// --- POST /token/rotate — atomico + reload live + chiusura WS; token MAI in risposta
|
|
255
|
+
// Contratto §4b(3): "scrittura atomica del token file + chiusura delle sessioni
|
|
256
|
+
// WS/API attive locali + reload credenziali proxy". Audit F7: prima questa route
|
|
257
|
+
// scriveva solo il file, e il server teneva il VECCHIO token in memoria -> restava
|
|
258
|
+
// accettato fino al restart manuale. Ora: (1) scrittura atomica, (2) reload live
|
|
259
|
+
// dell'holder in memoria (requireToken/verify vedono il nuovo), (3) chiusura delle
|
|
260
|
+
// sessioni WS long-lived (autenticate solo all'upgrade). Il nuovo token NON trapela
|
|
261
|
+
// mai: rotateToken() lo ritorna ma il valore viene SCARTATO, e reload() non lo espone.
|
|
262
|
+
r.post('/token/rotate', mutGate, (_req, res) => {
|
|
263
|
+
try {
|
|
264
|
+
rotateToken(tokenPath);
|
|
265
|
+
if (tokenStore && typeof tokenStore.reload === 'function') tokenStore.reload();
|
|
266
|
+
if (typeof closeSessions === 'function') closeSessions();
|
|
267
|
+
send(res, 200, {
|
|
268
|
+
rotated: true,
|
|
269
|
+
note: 'token ruotato: sessioni attive chiuse, vecchio token invalidato (401) — recupera il nuovo con `nexuscrew url`',
|
|
270
|
+
});
|
|
271
|
+
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// One-time PWA pairing capability. It is not the UI bearer token and is
|
|
275
|
+
// persisted hashed, 0600, for ten minutes only.
|
|
276
|
+
r.post('/peering/invite', mutGate, (_req, res) => {
|
|
277
|
+
try {
|
|
278
|
+
const st = nodesStore.loadOrInitStore(nodesPath);
|
|
279
|
+
send(res, 200, peering.createInvite({
|
|
280
|
+
invitesPath, instanceId: st.nodeId, port: cfg.port, label: os.hostname(),
|
|
281
|
+
}));
|
|
282
|
+
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// Hydra join: add once from the PWA. A provisional -L reaches the invite
|
|
286
|
+
// endpoint, then the negotiated -R makes the same SSH link reciprocal.
|
|
287
|
+
r.post('/nodes/pair', mutGate, async (req, res) => {
|
|
288
|
+
const b = req.body || {};
|
|
289
|
+
if (!validName(b.name) || !nodesStore.parseSshTarget(b.ssh)) {
|
|
290
|
+
return send(res, 400, { error: 'name o alias SSH non valido' });
|
|
291
|
+
}
|
|
292
|
+
const pair = peering.parsePairingUrl(b.pairingUrl);
|
|
293
|
+
if (!pair) return send(res, 400, { error: 'link di pairing non valido' });
|
|
294
|
+
if (b.sshPort !== undefined && !nodesStore.isPort(b.sshPort)) return send(res, 400, { error: 'sshPort non valida' });
|
|
295
|
+
if (b.identityFile !== undefined && !nodesStore.isAbsPath(b.identityFile)) return send(res, 400, { error: 'identityFile non valido' });
|
|
296
|
+
let provisionalPort = null;
|
|
297
|
+
let rollbackCredential = null;
|
|
298
|
+
let created = false;
|
|
299
|
+
try {
|
|
300
|
+
let st = nodesStore.loadOrInitStore(nodesPath);
|
|
301
|
+
const localPort = nodesCmds.assignLocalPort(st);
|
|
302
|
+
provisionalPort = localPort;
|
|
303
|
+
const acceptToken = crypto.randomBytes(32).toString('base64url');
|
|
304
|
+
st = nodesStore.addNode(st, {
|
|
305
|
+
name: b.name, ssh: b.ssh, sshPort: b.sshPort,
|
|
306
|
+
remotePort: pair.port, localPort, identityFile: b.identityFile,
|
|
307
|
+
transport: 'auto', autostart: true, visibility: 'network',
|
|
308
|
+
direction: 'outbound', acceptToken,
|
|
309
|
+
});
|
|
310
|
+
nodesStore.atomicWriteStore(nodesPath, st);
|
|
311
|
+
created = true;
|
|
312
|
+
const node = nodesStore.getNode(st, b.name);
|
|
313
|
+
const started = nodesTunnel.startForward({
|
|
314
|
+
home, node, localAppPort: cfg.port,
|
|
315
|
+
spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
|
|
316
|
+
sshBin: seams.sshBin, logFd: seams.logFd,
|
|
317
|
+
});
|
|
318
|
+
if (!started.started && started.reason !== 'already running') throw new Error(started.reason || 'tunnel start failed');
|
|
319
|
+
if (typeof seams.pairDelay === 'function') await seams.pairDelay();
|
|
320
|
+
else await new Promise((resolve) => setTimeout(resolve, 900));
|
|
321
|
+
const fetchImpl = seams.fetchImpl || fetch;
|
|
322
|
+
const jr = await fetchImpl(`http://127.0.0.1:${localPort}/pair/join`, {
|
|
323
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
324
|
+
body: JSON.stringify({
|
|
325
|
+
invite: pair.invite,
|
|
326
|
+
instanceId: st.nodeId,
|
|
327
|
+
name: String(b.localName || os.hostname()).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'node',
|
|
328
|
+
port: cfg.port,
|
|
329
|
+
acceptToken,
|
|
330
|
+
}),
|
|
331
|
+
});
|
|
332
|
+
const joined = await jr.json().catch(() => ({}));
|
|
333
|
+
if (!jr.ok || !nodesStore.validToken(joined.credential) || !nodesStore.isPort(joined.reversePort)
|
|
334
|
+
|| !nodesStore.NODE_ID_RE.test(joined.instanceId)) throw new Error(joined.error || `pairing HTTP ${jr.status}`);
|
|
335
|
+
rollbackCredential = joined.credential;
|
|
336
|
+
st = nodesStore.loadOrInitStore(nodesPath);
|
|
337
|
+
st = nodesStore.updateNode(st, b.name, {
|
|
338
|
+
token: joined.credential, nodeId: joined.instanceId, reversePort: joined.reversePort,
|
|
339
|
+
});
|
|
340
|
+
nodesStore.atomicWriteStore(nodesPath, st);
|
|
341
|
+
nodesTunnel.stopTunnel({ home, name: b.name });
|
|
342
|
+
const finalStart = nodesTunnel.startForward({
|
|
343
|
+
home, node: nodesStore.getNode(st, b.name), localAppPort: cfg.port,
|
|
344
|
+
spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
|
|
345
|
+
sshBin: seams.sshBin, logFd: seams.logFd,
|
|
346
|
+
});
|
|
347
|
+
if (!finalStart.started && finalStart.reason !== 'already running') throw new Error(finalStart.reason || 'final tunnel start failed');
|
|
348
|
+
if (typeof seams.pairDelay === 'function') await seams.pairDelay();
|
|
349
|
+
else await new Promise((resolve) => setTimeout(resolve, 900));
|
|
350
|
+
let confirmed = null;
|
|
351
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
352
|
+
try {
|
|
353
|
+
confirmed = await fetchImpl(`http://127.0.0.1:${localPort}/pair/confirm`, {
|
|
354
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
355
|
+
body: JSON.stringify({ credential: joined.credential }),
|
|
356
|
+
});
|
|
357
|
+
if (confirmed.ok) break;
|
|
358
|
+
} catch (_) {}
|
|
359
|
+
await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1)));
|
|
360
|
+
}
|
|
361
|
+
if (!confirmed || !confirmed.ok) {
|
|
362
|
+
const x = confirmed ? await confirmed.json().catch(() => ({})) : {};
|
|
363
|
+
throw new Error(x.error || `pair confirm HTTP ${confirmed ? confirmed.status : 'unreachable'}`);
|
|
364
|
+
}
|
|
365
|
+
send(res, 200, { paired: true, name: b.name, instanceId: joined.instanceId, transport: 'auto' });
|
|
366
|
+
} catch (e) {
|
|
367
|
+
if (rollbackCredential && provisionalPort) {
|
|
368
|
+
try {
|
|
369
|
+
const fetchImpl = seams.fetchImpl || fetch;
|
|
370
|
+
await fetchImpl(`http://127.0.0.1:${provisionalPort}/pair/cancel`, {
|
|
371
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
372
|
+
body: JSON.stringify({ instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId, credential: rollbackCredential }),
|
|
373
|
+
});
|
|
374
|
+
} catch (_) {}
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
378
|
+
const n = st && nodesStore.getNode(st, b.name);
|
|
379
|
+
if (n && created) {
|
|
380
|
+
nodesTunnel.stopTunnel({ home, name: b.name });
|
|
381
|
+
nodesStore.atomicWriteStore(nodesPath, nodesStore.removeNode(st, b.name));
|
|
382
|
+
}
|
|
383
|
+
} catch (_) {}
|
|
384
|
+
send(res, 502, { error: String(e.message || e) });
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// --- POST /nodes — nodes add (riusa nodesCmds.nodesAdd) --------------------
|
|
389
|
+
r.post('/nodes', mutGate, (req, res) => {
|
|
390
|
+
try {
|
|
391
|
+
const b = req.body;
|
|
392
|
+
if (!b || typeof b !== 'object' || Array.isArray(b)) {
|
|
393
|
+
return send(res, 400, { error: 'body deve essere un oggetto JSON' });
|
|
394
|
+
}
|
|
395
|
+
for (const k of Object.keys(b)) {
|
|
396
|
+
if (!ADD_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (schema: name, ssh, sshPort?, remotePort?, localPort?, keyPath?)` });
|
|
397
|
+
}
|
|
398
|
+
if (!validName(b.name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
|
|
399
|
+
if (!nodesStore.parseSsh(b.ssh)) return send(res, 400, { error: 'ssh non valido (atteso user@host strict)' });
|
|
400
|
+
if (b.sshPort !== undefined && !nodesStore.isPort(b.sshPort)) {
|
|
401
|
+
return send(res, 400, { error: 'sshPort deve essere un intero 1..65535' });
|
|
402
|
+
}
|
|
403
|
+
if (b.remotePort !== undefined && !nodesStore.isPort(b.remotePort)) {
|
|
404
|
+
return send(res, 400, { error: 'remotePort deve essere un intero 1..65535' });
|
|
405
|
+
}
|
|
406
|
+
if (b.localPort !== undefined && !nodesStore.isPort(b.localPort)) {
|
|
407
|
+
return send(res, 400, { error: 'localPort deve essere un intero 1..65535' });
|
|
408
|
+
}
|
|
409
|
+
if (b.keyPath !== undefined && !nodesStore.isAbsPath(b.keyPath)) {
|
|
410
|
+
return send(res, 400, { error: 'keyPath deve essere un path assoluto' });
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const cap = capture();
|
|
414
|
+
const out = nodesCmds.nodesAdd({
|
|
415
|
+
...cliOpts(cap),
|
|
416
|
+
name: b.name, ssh: b.ssh, sshPort: b.sshPort,
|
|
417
|
+
remotePort: b.remotePort, localPort: b.localPort, key: b.keyPath,
|
|
418
|
+
});
|
|
419
|
+
if (out.code === 0) {
|
|
420
|
+
return send(res, 200, {
|
|
421
|
+
added: true,
|
|
422
|
+
name: out.name,
|
|
423
|
+
sshPort: out.sshPort,
|
|
424
|
+
remotePort: out.remotePort,
|
|
425
|
+
localPort: out.localPort,
|
|
426
|
+
// La riga authorized_keys NON e' un segreto: pubkey con restrict/permitopen.
|
|
427
|
+
authorizedKeys: authorizedKeysLine(cap),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
const msg = lastLine(cap, 'nodes add fallito');
|
|
431
|
+
if (out.reason === 'readonly') return send(res, 403, { error: msg });
|
|
432
|
+
if (out.reason === 'add rifiutato') {
|
|
433
|
+
// duplicato/self-reference = conflitto (409); schema rifiutato = 400.
|
|
434
|
+
const status = /duplicato|self-reference/.test(msg) ? 409 : 400;
|
|
435
|
+
return send(res, status, { error: msg });
|
|
436
|
+
}
|
|
437
|
+
// store invalido / keygen fallita / write fallita: errore lato server.
|
|
438
|
+
return send(res, 500, { error: msg });
|
|
439
|
+
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// --- DELETE /nodes/:name — nodes remove ------------------------------------
|
|
443
|
+
r.delete('/nodes/:name', mutGate, (req, res) => {
|
|
444
|
+
try {
|
|
445
|
+
const name = String(req.params.name || '');
|
|
446
|
+
if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
|
|
447
|
+
const cap = capture();
|
|
448
|
+
const out = nodesCmds.nodesRemove({ ...cliOpts(cap), name });
|
|
449
|
+
if (out.code === 0) return send(res, 200, { removed: true, name, stopped: !!out.stopped });
|
|
450
|
+
const msg = lastLine(cap, 'nodes remove fallito');
|
|
451
|
+
if (out.reason === 'readonly') return send(res, 403, { error: msg });
|
|
452
|
+
const status = /sconosciuto|nessun nodes\.json/.test(msg) ? 404 : 500;
|
|
453
|
+
send(res, status, { error: msg });
|
|
454
|
+
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// --- POST /nodes/:name/test — non-mutante (POST per coerenza azione) -------
|
|
458
|
+
// NON gated READONLY: e' una probe diagnostica, coerente col `nodes test` CLI.
|
|
459
|
+
r.post('/nodes/:name/test', async (req, res) => {
|
|
460
|
+
try {
|
|
461
|
+
const name = String(req.params.name || '');
|
|
462
|
+
if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
|
|
463
|
+
const cap = capture();
|
|
464
|
+
const out = await nodesCmds.nodesTest({ ...cliOpts(cap), name });
|
|
465
|
+
if (out.result === 'unknown-node') return send(res, 404, { error: `nodo sconosciuto "${name}"` });
|
|
466
|
+
// result distingue: ok | tunnel-down | health-ko | token-missing | token-ko
|
|
467
|
+
send(res, 200, { ok: out.result === 'ok', result: out.result, detail: lastLine(cap, '') });
|
|
468
|
+
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// --- POST /nodes/:name/up|down|restart — lifecycle tunnel ------------------
|
|
472
|
+
// GATE READONLY (audit F6, contract §4b(6): "restart processi tunnel" e' tra i
|
|
473
|
+
// mutanti della lista chiusa e NEXUSCREW_READONLY blocca TUTTI i mutanti). Prima
|
|
474
|
+
// questi endpoint erano esplicitamente NON gated ("decisione B0") — ma cio'
|
|
475
|
+
// contradiceva il contratto duro §4b(6): up/down/restart mutano processi e vanno
|
|
476
|
+
// bloccati in READONLY. /nodes/:name/test resta NON gated (probe diagnostica).
|
|
477
|
+
function lifecycleHandler(action) {
|
|
478
|
+
return (req, res) => {
|
|
479
|
+
try {
|
|
480
|
+
const name = String(req.params.name || '');
|
|
481
|
+
if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
|
|
482
|
+
const cap = capture();
|
|
483
|
+
const fn = action === 'up' ? nodesCmds.nodesUp
|
|
484
|
+
: action === 'down' ? nodesCmds.nodesDown
|
|
485
|
+
: nodesCmds.nodesRestart;
|
|
486
|
+
const out = fn({ ...cliOpts(cap), name });
|
|
487
|
+
if (out.code !== 0) {
|
|
488
|
+
const msg = lastLine(cap, `nodes ${action} fallito`);
|
|
489
|
+
const status = /sconosciuto/.test(msg) ? 404 : 500;
|
|
490
|
+
return send(res, status, { error: msg });
|
|
491
|
+
}
|
|
492
|
+
if (action === 'up') return send(res, 200, { name, started: out.started, pid: out.pid });
|
|
493
|
+
if (action === 'down') return send(res, 200, { name, stopped: out.stopped });
|
|
494
|
+
return send(res, 200, { name, restarted: true, pid: out.pid });
|
|
495
|
+
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
r.post('/nodes/:name/up', mutGate, lifecycleHandler('up'));
|
|
499
|
+
r.post('/nodes/:name/down', mutGate, lifecycleHandler('down'));
|
|
500
|
+
r.post('/nodes/:name/restart', mutGate, lifecycleHandler('restart'));
|
|
501
|
+
r.patch('/nodes/:name/visibility', mutGate, (req, res) => {
|
|
502
|
+
try {
|
|
503
|
+
const name = String(req.params.name || '');
|
|
504
|
+
const visibility = req.body && req.body.visibility;
|
|
505
|
+
const selected = (req.body && req.body.selected) || [];
|
|
506
|
+
if (!validName(name) || !['network', 'relay-only', 'selected'].includes(visibility)) {
|
|
507
|
+
return send(res, 400, { error: 'visibility non valida' });
|
|
508
|
+
}
|
|
509
|
+
let st = nodesStore.loadOrInitStore(nodesPath);
|
|
510
|
+
st = nodesStore.updateNode(st, name, { visibility, selected: visibility === 'selected' ? selected : [] });
|
|
511
|
+
nodesStore.atomicWriteStore(nodesPath, st);
|
|
512
|
+
send(res, 200, { saved: true, name, visibility });
|
|
513
|
+
} catch (e) { send(res, 400, { error: String(e.message || e) }); }
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// --- POST /node-role — node on/off (rendezvous config) ---------------------
|
|
517
|
+
r.post('/node-role', mutGate, (req, res) => {
|
|
518
|
+
try {
|
|
519
|
+
const b = req.body;
|
|
520
|
+
if (!b || typeof b !== 'object' || Array.isArray(b)) {
|
|
521
|
+
return send(res, 400, { error: 'body deve essere un oggetto JSON' });
|
|
522
|
+
}
|
|
523
|
+
for (const k of Object.keys(b)) {
|
|
524
|
+
if (!NODE_ROLE_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (schema: enabled, rendezvousSsh?, publishedPort?, keyPath?)` });
|
|
525
|
+
}
|
|
526
|
+
if (typeof b.enabled !== 'boolean') return send(res, 400, { error: 'enabled deve essere boolean' });
|
|
527
|
+
if (b.rendezvousSsh !== undefined && !nodesStore.parseSsh(b.rendezvousSsh)) {
|
|
528
|
+
return send(res, 400, { error: 'rendezvousSsh non valido (atteso user@host strict)' });
|
|
529
|
+
}
|
|
530
|
+
if (b.publishedPort !== undefined && !nodesStore.isPort(b.publishedPort)) {
|
|
531
|
+
return send(res, 400, { error: 'publishedPort deve essere un intero 1..65535' });
|
|
532
|
+
}
|
|
533
|
+
if (b.keyPath !== undefined && !nodesStore.isAbsPath(b.keyPath)) {
|
|
534
|
+
return send(res, 400, { error: 'keyPath deve essere un path assoluto' });
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const cap = capture();
|
|
538
|
+
if (b.enabled === false) {
|
|
539
|
+
const out = nodesCmds.nodeOff({ ...cliOpts(cap) });
|
|
540
|
+
if (out.code === 0) return send(res, 200, { enabled: false, roles: out.roles });
|
|
541
|
+
return send(res, 500, { error: lastLine(cap, 'node off fallito') });
|
|
542
|
+
}
|
|
543
|
+
const out = nodesCmds.nodeOn({
|
|
544
|
+
...cliOpts(cap),
|
|
545
|
+
rendezvousSsh: b.rendezvousSsh,
|
|
546
|
+
publishedPort: b.publishedPort,
|
|
547
|
+
key: b.keyPath,
|
|
548
|
+
port: cfg.port, // porta nexus locale da esporre = quella del server attivo
|
|
549
|
+
});
|
|
550
|
+
if (out.code === 0) {
|
|
551
|
+
const resp = { enabled: true, roles: out.roles, tunnel: out.tunnel || null };
|
|
552
|
+
const line = authorizedKeysLine(cap);
|
|
553
|
+
if (line) resp.authorizedKeys = line; // pubkey con permitlisten: non un segreto
|
|
554
|
+
return send(res, 200, resp);
|
|
555
|
+
}
|
|
556
|
+
const msg = lastLine(cap, 'node on fallito');
|
|
557
|
+
if (out.reason === 'readonly') return send(res, 403, { error: msg });
|
|
558
|
+
if (out.reason === 'no rendezvous') return send(res, 400, { error: msg });
|
|
559
|
+
if (out.reason === 'permitlisten') return send(res, 409, { error: msg });
|
|
560
|
+
send(res, 500, { error: msg });
|
|
561
|
+
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
// --- POST /service/regenerate — rigenera l'unit service --------------------
|
|
565
|
+
// Riusa generateService/installService (lib/cli/service.js: escaping per-platform,
|
|
566
|
+
// no-symlink, tmp+rename atomico). L'execImpl e' un NO-OP che registra i comandi
|
|
567
|
+
// di attivazione SENZA eseguirli: il contratto B2 vieta il restart automatico
|
|
568
|
+
// dall'API (la UI avvisa di riavviare a mano).
|
|
569
|
+
r.post('/service/regenerate', mutGate, (_req, res) => {
|
|
570
|
+
try {
|
|
571
|
+
const { cfg: fileCfg } = readConfigFile(configPath);
|
|
572
|
+
const port = nodesStore.isPort(fileCfg.port) ? fileCfg.port : cfg.port;
|
|
573
|
+
const ctx = {
|
|
574
|
+
repoRoot: repoRoot(),
|
|
575
|
+
nodeBin: nodeBin(),
|
|
576
|
+
port, home,
|
|
577
|
+
uid: seams.uid || uid(),
|
|
578
|
+
installPath: seams.serviceInstallPath,
|
|
579
|
+
};
|
|
580
|
+
const content = generateService(platform, ctx);
|
|
581
|
+
const skipped = [];
|
|
582
|
+
const noExec = (bin, args) => { skipped.push(`${bin} ${(args || []).join(' ')}`); };
|
|
583
|
+
const out = installService(platform, content, ctx, { execImpl: noExec });
|
|
584
|
+
send(res, 200, {
|
|
585
|
+
regenerated: true,
|
|
586
|
+
target: out.target,
|
|
587
|
+
note: 'unit rigenerata; nessun restart automatico — riavvia il service per applicarla',
|
|
588
|
+
skippedActivation: skipped,
|
|
589
|
+
});
|
|
590
|
+
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// Error handler del router: body JSON malformato (express.json) -> 400 con causa;
|
|
594
|
+
// qualunque altro errore -> status coerente. MAI stack trace in risposta.
|
|
595
|
+
// eslint-disable-next-line no-unused-vars
|
|
596
|
+
r.use((err, _req, res, _next) => {
|
|
597
|
+
if (err && err.type === 'entity.parse.failed') {
|
|
598
|
+
return send(res, 400, { error: 'body JSON non valido' });
|
|
599
|
+
}
|
|
600
|
+
if (err && err.type === 'entity.too.large') {
|
|
601
|
+
return send(res, 400, { error: 'body troppo grande (limite 8kb)' });
|
|
602
|
+
}
|
|
603
|
+
send(res, (err && err.status) || 500, { error: String((err && err.message) || err) });
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
return r;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Public only because the one-time invite itself is the capability. The route
|
|
610
|
+
// exposes no generic API and creates a scoped peer credential, never a UI token.
|
|
611
|
+
function publicPeeringRoutes(deps = {}) {
|
|
612
|
+
const cfg = deps.cfg || {};
|
|
613
|
+
const home = cfg.home || os.homedir();
|
|
614
|
+
const nodesPath = deps.nodesPath || cfg.nodesPath || nodesStore.defaultNodesPath(home);
|
|
615
|
+
const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
|
|
616
|
+
const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
|
|
617
|
+
const r = express.Router();
|
|
618
|
+
const attempts = new Map();
|
|
619
|
+
r.use(express.json({ limit: '8kb' }));
|
|
620
|
+
r.post('/join', (req, res) => {
|
|
621
|
+
const key = String(req.socket && req.socket.remoteAddress || 'local');
|
|
622
|
+
const now = Date.now();
|
|
623
|
+
const recent = (attempts.get(key) || []).filter((x) => now - x < 60_000);
|
|
624
|
+
recent.push(now); attempts.set(key, recent);
|
|
625
|
+
if (recent.length > 10) return res.status(429).json({ error: 'troppi tentativi di pairing' });
|
|
626
|
+
const b = req.body || {};
|
|
627
|
+
if (!nodesStore.validToken(b.invite) || !nodesStore.NODE_ID_RE.test(b.instanceId)
|
|
628
|
+
|| !validPeerName(b.name) || !nodesStore.isPort(b.port) || !nodesStore.validToken(b.acceptToken)) {
|
|
629
|
+
return res.status(400).json({ error: 'pairing request non valida' });
|
|
630
|
+
}
|
|
631
|
+
if (!peering.consumeInvite({ invitesPath, invite: b.invite })) return res.status(410).json({ error: 'invito scaduto o gia usato' });
|
|
632
|
+
try {
|
|
633
|
+
const st = nodesStore.loadOrInitStore(nodesPath);
|
|
634
|
+
if (st.nodeId === b.instanceId || st.nodes.some((n) => n.nodeId === b.instanceId)) return res.status(409).json({ error: 'peer duplicato' });
|
|
635
|
+
let name = b.name;
|
|
636
|
+
for (let i = 2; nodesStore.getNode(st, name); i += 1) name = `${b.name.slice(0, 28)}-${i}`;
|
|
637
|
+
const reversePort = peering.allocateReversePort(st.nodes);
|
|
638
|
+
const credential = peering.createPending({ pendingPath, data: {
|
|
639
|
+
name, remotePort: b.port, reversePort, instanceId: b.instanceId, acceptToken: b.acceptToken,
|
|
640
|
+
} });
|
|
641
|
+
res.json({ paired: true, instanceId: st.nodeId, reversePort, credential });
|
|
642
|
+
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
643
|
+
});
|
|
644
|
+
r.post('/confirm', (req, res) => {
|
|
645
|
+
const b = req.body || {};
|
|
646
|
+
if (!nodesStore.validToken(b.credential)) return res.status(400).json({ error: 'confirm non valido' });
|
|
647
|
+
const pending = peering.consumePending({ pendingPath, credential: b.credential });
|
|
648
|
+
if (!pending) {
|
|
649
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
650
|
+
if (st && st.nodes.some((n) => n.acceptToken && peering.safeEqual(n.acceptToken, b.credential))) return res.json({ confirmed: true, idempotent: true });
|
|
651
|
+
return res.status(410).json({ error: 'pairing pending scaduto o gia usato' });
|
|
652
|
+
}
|
|
653
|
+
try {
|
|
654
|
+
let st = nodesStore.loadOrInitStore(nodesPath);
|
|
655
|
+
if (st.nodeId === pending.instanceId || st.nodes.some((n) => n.nodeId === pending.instanceId)) return res.status(409).json({ error: 'peer duplicato' });
|
|
656
|
+
st = nodesStore.addNode(st, {
|
|
657
|
+
name: pending.name, remotePort: pending.remotePort, localPort: pending.reversePort,
|
|
658
|
+
direction: 'inbound', transport: 'inbound', autostart: true,
|
|
659
|
+
visibility: 'network', nodeId: pending.instanceId,
|
|
660
|
+
token: pending.acceptToken, acceptToken: b.credential,
|
|
661
|
+
});
|
|
662
|
+
nodesStore.atomicWriteStore(nodesPath, st);
|
|
663
|
+
res.json({ confirmed: true });
|
|
664
|
+
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
665
|
+
});
|
|
666
|
+
r.post('/cancel', (req, res) => {
|
|
667
|
+
const b = req.body || {};
|
|
668
|
+
if (!nodesStore.NODE_ID_RE.test(b.instanceId) || !nodesStore.validToken(b.credential)) return res.status(400).json({ error: 'cancel non valido' });
|
|
669
|
+
try {
|
|
670
|
+
const pending = peering.consumePending({ pendingPath, credential: b.credential });
|
|
671
|
+
if (!pending || pending.instanceId !== b.instanceId) {
|
|
672
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
673
|
+
const peer = st && st.nodes.find((n) => n.nodeId === b.instanceId && n.acceptToken && peering.safeEqual(n.acceptToken, b.credential));
|
|
674
|
+
if (!peer) return res.status(404).json({ error: 'pair non trovato' });
|
|
675
|
+
nodesStore.atomicWriteStore(nodesPath, nodesStore.removeNode(st, peer.name));
|
|
676
|
+
}
|
|
677
|
+
res.json({ cancelled: true });
|
|
678
|
+
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
679
|
+
});
|
|
680
|
+
return r;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function validPeerName(name) { return typeof name === 'string' && nodesStore.NODE_NAME_RE.test(name); }
|
|
684
|
+
|
|
685
|
+
module.exports = { settingsRoutes, publicPeeringRoutes, deepScrubTokens };
|