@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.
Files changed (51) hide show
  1. package/README.md +153 -25
  2. package/bin/nexuscrew.js +10 -4
  3. package/frontend/dist/assets/index-COsoJxXQ.css +32 -0
  4. package/frontend/dist/assets/index-Dey9rdY-.js +90 -0
  5. package/frontend/dist/assets/qr-scanner-worker.min-D85Z9gVD.js +98 -0
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/dist/sw.js +29 -0
  8. package/frontend/dist/version.json +1 -0
  9. package/lib/auth/middleware.js +7 -1
  10. package/lib/auth/token.js +31 -1
  11. package/lib/cli/commands.js +502 -43
  12. package/lib/cli/doctor.js +160 -0
  13. package/lib/cli/fleet-service.js +16 -3
  14. package/lib/cli/init.js +69 -11
  15. package/lib/cli/path.js +24 -0
  16. package/lib/cli/service.js +14 -8
  17. package/lib/cli/url.js +63 -0
  18. package/lib/config.js +5 -0
  19. package/lib/decks/routes.js +81 -0
  20. package/lib/decks/store.js +123 -0
  21. package/lib/files/routes.js +57 -1
  22. package/lib/files/store.js +16 -1
  23. package/lib/fleet/builtin.js +151 -24
  24. package/lib/fleet/definitions.js +26 -0
  25. package/lib/fleet/managed.js +381 -0
  26. package/lib/fleet/routes.js +5 -4
  27. package/lib/mcp/server.js +381 -0
  28. package/lib/nodes/commands.js +411 -0
  29. package/lib/nodes/peering.js +116 -0
  30. package/lib/nodes/store.js +425 -0
  31. package/lib/nodes/tunnel-supervisor.js +102 -0
  32. package/lib/nodes/tunnel.js +315 -0
  33. package/lib/notify/asks.js +150 -0
  34. package/lib/notify/events.js +59 -0
  35. package/lib/notify/notifier.js +37 -0
  36. package/lib/notify/persist.js +73 -0
  37. package/lib/notify/push.js +289 -0
  38. package/lib/notify/routes.js +260 -0
  39. package/lib/proxy/federation.js +217 -0
  40. package/lib/proxy/node-proxy.js +305 -0
  41. package/lib/pty/attach.js +34 -9
  42. package/lib/pty/provider.js +21 -6
  43. package/lib/server.js +291 -14
  44. package/lib/settings/routes.js +685 -0
  45. package/lib/ws/bridge.js +10 -1
  46. package/package.json +8 -2
  47. package/skills/nexuscrew-agent/SKILL.md +83 -0
  48. package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
  49. package/skills/nexuscrew-agent/bin/nc-send +48 -0
  50. package/frontend/dist/assets/index-DUbtTZMj.css +0 -32
  51. package/frontend/dist/assets/index-EVa9bnAC.js +0 -81
@@ -0,0 +1,217 @@
1
+ 'use strict';
2
+
3
+ const http = require('node:http');
4
+ const net = require('node:net');
5
+ const express = require('express');
6
+ const store = require('../nodes/store.js');
7
+ const { bearerFrom } = require('../auth/middleware.js');
8
+ const { safeEqual } = require('../nodes/peering.js');
9
+ const {
10
+ sanitizeRequestHeaders, sanitizeResponseHeaders, stripLocalTokenQuery,
11
+ } = require('./node-proxy.js');
12
+
13
+ const MAX_HOPS = 4;
14
+ const ROUTE_DELIMITER = '_';
15
+
16
+ function peerFromToken(nodesPath, token) {
17
+ const st = store.loadStore(nodesPath);
18
+ if (!st || !token) return null;
19
+ return st.nodes.find((n) => n.acceptToken && safeEqual(n.acceptToken, token)) || null;
20
+ }
21
+
22
+ function peerAllows(peer, otherId) {
23
+ if (!peer) return true;
24
+ if (peer.visibility === 'network') return true;
25
+ if (peer.visibility === 'relay-only') return false;
26
+ return Array.isArray(peer.selected) && peer.selected.includes(otherId);
27
+ }
28
+
29
+ function canTransit(ingress, egress) {
30
+ if (!ingress || !egress || ingress.name === egress.name) return !ingress;
31
+ return peerAllows(ingress, egress.nodeId) && peerAllows(egress, ingress.nodeId);
32
+ }
33
+
34
+ function parseRoute(raw) {
35
+ const parts = String(raw || '').split('?')[0].split('/').filter(Boolean);
36
+ const idx = parts.indexOf(ROUTE_DELIMITER);
37
+ // The delimiter cannot occur in a strict peer name, so the first occurrence
38
+ // is authoritative. Resource segments may legitimately be "_" (a valid
39
+ // tmux session name) and must not be mistaken for another boundary.
40
+ if (idx < 0) return null;
41
+ const route = parts.slice(0, idx);
42
+ const resource = `/${parts.slice(idx + 1).join('/')}`;
43
+ if (route.length > MAX_HOPS || route.some((n) => !store.NODE_NAME_RE.test(n))) return null;
44
+ if (new Set(route).size !== route.length || !knownResource(resource)) return null;
45
+ return { route, resource };
46
+ }
47
+
48
+ function knownResource(resource) {
49
+ return resource === '/sessions'
50
+ || /^\/sessions\/[\w.@%:+-]{1,128}$/.test(resource)
51
+ || resource === '/config'
52
+ || resource === '/fs/dirs'
53
+ || resource === '/files'
54
+ || resource === '/files/download'
55
+ || resource === '/files/upload'
56
+ || resource === '/ws';
57
+ }
58
+
59
+ function allowedResource(resource, method = 'GET') {
60
+ if (resource === '/sessions') return method === 'GET' || method === 'POST';
61
+ if (/^\/sessions\/[\w.@%:+-]{1,128}$/.test(resource)) return method === 'DELETE';
62
+ if (resource === '/config') return method === 'GET';
63
+ if (resource === '/fs/dirs') return method === 'GET';
64
+ if (resource === '/files') return method === 'GET' || method === 'DELETE';
65
+ if (resource === '/files/download') return method === 'GET';
66
+ if (resource === '/files/upload') return method === 'POST';
67
+ if (resource === '/ws') return method === 'GET';
68
+ return false;
69
+ }
70
+
71
+ function cleanHeaders(headers, credential, visited = null) {
72
+ const out = sanitizeRequestHeaders(headers, credential);
73
+ for (const key of Object.keys(out)) {
74
+ if (['x-nexuscrew-route', 'x-nexuscrew-visited', 'x-nexuscrew-hop'].includes(key.toLowerCase())) delete out[key];
75
+ }
76
+ if (Array.isArray(visited) && visited.length) out['x-nexuscrew-visited'] = visited.join(',');
77
+ return out;
78
+ }
79
+
80
+ function proxyHttp(req, res, { port, path, credential, visited = null }) {
81
+ const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited) }, (r) => {
82
+ res.writeHead(r.statusCode, sanitizeResponseHeaders(r.headers)); r.pipe(res);
83
+ });
84
+ up.setTimeout(30000, () => up.destroy());
85
+ up.on('error', () => { if (!res.headersSent) res.status(502).json({ error: 'peer non raggiungibile' }); else res.destroy(); });
86
+ req.pipe(up);
87
+ }
88
+
89
+ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false }) {
90
+ return (req, res) => {
91
+ const parsed = parseRoute(req.url);
92
+ if (!parsed || !allowedResource(parsed.resource, req.method)) return res.status(404).json({ error: 'not found' });
93
+ if (readonly() && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) return res.status(403).json({ error: 'READONLY: federated mutation blocked' });
94
+ const st = store.loadStore(nodesPath);
95
+ if (!st) return res.status(503).json({ error: 'node store unavailable' });
96
+ const visited = controlledVisited(req, ingress, st.nodeId);
97
+ if (!visited) return res.status(409).json({ error: 'federation cycle rejected' });
98
+ if (parsed.route.length === 0) {
99
+ return proxyHttp(req, res, { port: typeof localPort === 'function' ? localPort() : localPort, path: `/api${parsed.resource}${queryOf(req.url)}`, credential: localCredential() });
100
+ }
101
+ const next = st && store.getNode(st, parsed.route[0]);
102
+ if (!next || !next.token || (ingress && !canTransit(ingress, next))) return res.status(403).json({ error: 'route non consentita' });
103
+ const rest = parsed.route.slice(1);
104
+ const path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}${parsed.resource}${queryOf(req.url)}`;
105
+ proxyHttp(req, res, { port: next.localPort, path, credential: next.token, visited });
106
+ };
107
+ }
108
+
109
+ function queryOf(url) {
110
+ const i = String(url).indexOf('?');
111
+ return i < 0 ? '' : stripLocalTokenQuery(String(url).slice(i));
112
+ }
113
+
114
+ function controlledVisited(req, ingress, instanceId) {
115
+ const raw = ingress ? String(req.headers['x-nexuscrew-visited'] || '') : '';
116
+ const seen = raw ? raw.split(',').filter(Boolean) : [];
117
+ if (seen.some((id) => !store.NODE_ID_RE.test(id)) || seen.includes(instanceId) || seen.length > MAX_HOPS) return null;
118
+ return [...seen, instanceId];
119
+ }
120
+
121
+ async function collectTopology({ nodesPath, ingress = null, ttl = MAX_HOPS, visited = [], fetchImpl = fetch }) {
122
+ const st = store.loadStore(nodesPath);
123
+ if (!st) return { instanceId: null, nodes: [] };
124
+ const seen = new Set(visited.filter((x) => store.NODE_ID_RE.test(x)));
125
+ seen.add(st.nodeId);
126
+ const out = [];
127
+ for (const n of st.nodes) {
128
+ if (!n.nodeId || seen.has(n.nodeId) || (ingress && !canTransit(ingress, n))) continue;
129
+ out.push({ instanceId: n.nodeId, name: n.name, route: [n.name], direct: true });
130
+ if (ttl <= 1 || !n.token) continue;
131
+ try {
132
+ const u = `http://127.0.0.1:${n.localPort}/federation/topology?ttl=${ttl - 1}&visited=${encodeURIComponent([...seen].join(','))}`;
133
+ const r = await fetchImpl(u, { headers: { authorization: `Bearer ${n.token}` } });
134
+ const j = await r.json();
135
+ if (!r.ok || j.instanceId !== n.nodeId || !Array.isArray(j.nodes)) continue;
136
+ for (const child of j.nodes) {
137
+ if (!child || !store.NODE_ID_RE.test(child.instanceId) || child.instanceId === n.nodeId || seen.has(child.instanceId)
138
+ || !store.NODE_NAME_RE.test(child.name)
139
+ || !Array.isArray(child.route) || child.route.length < 1 || child.route.length >= ttl
140
+ || child.route.some((x) => !store.NODE_NAME_RE.test(x))
141
+ || new Set(child.route).size !== child.route.length
142
+ || child.name !== child.route[child.route.length - 1]
143
+ || child.route.includes(n.name)) continue;
144
+ out.push({ instanceId: child.instanceId, name: child.name, route: [n.name, ...child.route], direct: false });
145
+ }
146
+ } catch (_) {}
147
+ }
148
+ const ids = new Set(); const routes = new Set(); const unique = [];
149
+ for (const n of out.sort((a, b) => a.route.length - b.route.length)) {
150
+ const routeKey = n.route.join('/');
151
+ if (ids.has(n.instanceId) || routes.has(routeKey)) continue;
152
+ ids.add(n.instanceId); routes.add(routeKey); unique.push(n);
153
+ }
154
+ return { instanceId: st.nodeId, nodes: unique };
155
+ }
156
+
157
+ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly }) {
158
+ const r = express.Router();
159
+ r.use((req, res, next) => {
160
+ const peer = peerFromToken(nodesPath, bearerFrom(req));
161
+ if (!peer) return res.status(401).json({ error: 'unauthorized peer' });
162
+ req.peer = peer; next();
163
+ });
164
+ r.get('/topology', async (req, res) => {
165
+ const ttl = Math.max(0, Math.min(MAX_HOPS, Number(req.query.ttl) || MAX_HOPS));
166
+ const visited = String(req.query.visited || '').split(',');
167
+ res.json(await collectTopology({ nodesPath, ingress: req.peer, ttl, visited, fetchImpl }));
168
+ });
169
+ r.use('/route', (req, res) => routeHandler({ nodesPath, localPort, localCredential, ingress: req.peer, readonly })(req, res));
170
+ return r;
171
+ }
172
+
173
+ function localRouter({ nodesPath, localPort, localCredential, readonly }) {
174
+ const r = express.Router();
175
+ r.use((req, res) => routeHandler({ nodesPath, localPort, localCredential, readonly })(req, res));
176
+ return r;
177
+ }
178
+
179
+ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredential, ingress, readonly = () => false, activeSockets = null }) {
180
+ if (readonly()) return reject(socket, 403);
181
+ const parsed = parseRoute(req.url.replace(/^\/(?:api\/route|federation\/route)/, ''));
182
+ if (!parsed || parsed.resource !== '/ws') return reject(socket, 404);
183
+ const st = store.loadStore(nodesPath);
184
+ if (!st) return reject(socket, 503);
185
+ const visited = controlledVisited(req, ingress, st.nodeId);
186
+ if (!visited) return reject(socket, 409);
187
+ let port = typeof localPort === 'function' ? localPort() : localPort; let credential = localCredential(); let path = '/ws';
188
+ if (parsed.route.length) {
189
+ const next = store.getNode(st, parsed.route[0]);
190
+ if (!next || !next.token || (ingress && !canTransit(ingress, next))) return reject(socket, 403);
191
+ port = next.localPort; credential = next.token;
192
+ const rest = parsed.route.slice(1);
193
+ path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}/ws`;
194
+ }
195
+ const up = net.connect({ host: '127.0.0.1', port });
196
+ up.once('connect', () => {
197
+ const headers = cleanHeaders(req.headers, credential, parsed.route.length ? visited : null);
198
+ const lines = [`GET ${path} HTTP/1.1`, `Host: 127.0.0.1:${port}`];
199
+ for (const [k, v] of Object.entries(headers)) lines.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
200
+ lines.push('Connection: Upgrade', 'Upgrade: websocket', '', '');
201
+ up.write(lines.join('\r\n')); if (head && head.length) up.write(head);
202
+ if (activeSockets && typeof activeSockets.add === 'function') {
203
+ activeSockets.add(socket); activeSockets.add(up);
204
+ const remove = () => { activeSockets.delete(socket); activeSockets.delete(up); };
205
+ socket.once('close', remove); up.once('close', remove);
206
+ }
207
+ socket.pipe(up); up.pipe(socket);
208
+ });
209
+ up.on('error', () => reject(socket, 502)); socket.on('error', () => up.destroy());
210
+ }
211
+
212
+ function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConnection: close\r\n\r\n`); } catch (_) {} }
213
+
214
+ module.exports = {
215
+ MAX_HOPS, ROUTE_DELIMITER, peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource,
216
+ collectTopology, peerRouter, localRouter, forwardUpgrade,
217
+ };
@@ -0,0 +1,305 @@
1
+ 'use strict';
2
+ // lib/proxy/node-proxy.js — reverse-proxy single-origin /node/<name> (design §4, §4b(2)).
3
+ //
4
+ // La superficie PIU' security-critical del progetto. Contratti duri (§4b(2)):
5
+ // 1. Il token LOCALE (Bearer) si verifica PRIMA di risolvere <name>. Il router
6
+ // HTTP e' montato DIETRO requireToken; l'upgrade WS verifica il token in testa
7
+ // a handleNodeUpgrade, prima di qualunque parsing/resolve.
8
+ // 2. <name> = chiave strict di nodes.json (^[a-z0-9-]{1,32}$), MAI usata per
9
+ // costruire path/URL filesystem; nome non in config -> 404 secco.
10
+ // 3. Il token del nodo remoto lo inietta SOLO il proxy (da nodes.json via B0); il
11
+ // browser non lo vede MAI. Header hop-by-hop e Authorization/cookie/host/
12
+ // x-forwarded/proxy-* client-supplied STRIPPATI, mai inoltrati upstream.
13
+ // 4. Upstream consentito: ESCLUSIVAMENTE 127.0.0.1:<localPort> del tunnel da
14
+ // config — mai derivato da input utente/header.
15
+ // 5. Upgrade WS: STESSI check dell'HTTP (auth locale -> name strict -> inject
16
+ // token remoto). Parita' HTTP/WS.
17
+ // 7. NIENTE proxy transitivo: /node/<a>/node/<b> -> 404 (espone SOLO la root
18
+ // locale del nodo remoto, mai il suo /node/*).
19
+ // 8. Nodo irraggiungibile -> 502 JSON {error}, mai hang: timeout esplicito.
20
+ //
21
+ // Niente shell, niente http-proxy: proxy manuale con http.request (HTTP) e piping
22
+ // raw socket TCP (upgrade WS). resolveNode/httpRequest/connect iniettabili (test).
23
+ const http = require('node:http');
24
+ const net = require('node:net');
25
+ const { NODE_NAME_RE } = require('../nodes/store.js');
26
+ const { bearerFrom } = require('../auth/middleware.js');
27
+
28
+ // Timeout upstream: irraggiungibile/lento -> 502, mai spinner infinito (§8).
29
+ const PROXY_TIMEOUT_MS = 30000;
30
+ const CONNECT_TIMEOUT_MS = 10000;
31
+
32
+ // Metodi che mutano stato sul nodo remoto: bloccati sotto NEXUSCREW_READONLY locale.
33
+ const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
34
+
35
+ // Hop-by-hop (RFC 7230 §6.1) + Proxy-*: mai inoltrati end-to-end.
36
+ const HOP_BY_HOP = new Set([
37
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
38
+ 'te', 'trailer', 'transfer-encoding', 'upgrade',
39
+ ]);
40
+
41
+ // Header della RICHIESTA client da NON inoltrare upstream: hop-by-hop + i
42
+ // client-supplied che potrebbero (a) impersonare/derivare l'upstream o (b)
43
+ // confondere l'auth remota. Authorization viene RI-iniettato col token del nodo.
44
+ function isStrippedRequestHeader(lk) {
45
+ if (HOP_BY_HOP.has(lk)) return true;
46
+ if (lk === 'authorization' || lk === 'cookie' || lk === 'host') return true;
47
+ if (lk.startsWith('proxy-')) return true;
48
+ if (lk === 'forwarded' || lk.startsWith('x-forwarded-')) return true;
49
+ return false;
50
+ }
51
+
52
+ function connectionOptions(headers) {
53
+ const names = new Set();
54
+ for (const [key, value] of Object.entries(headers || {})) {
55
+ if (key.toLowerCase() !== 'connection') continue;
56
+ const raw = Array.isArray(value) ? value.join(',') : String(value || '');
57
+ for (const item of raw.split(',')) {
58
+ const name = item.trim().toLowerCase();
59
+ if (/^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(name)) names.add(name);
60
+ }
61
+ }
62
+ return names;
63
+ }
64
+
65
+ // Divide "/vps/api/x?y=1" -> { name:'vps', rest:'/api/x', search:'?y=1' }.
66
+ // name RAW (non decodificato): validato poi contro l'allowlist strict, cosi'
67
+ // '..', '%2e', nomi lunghi falliscono il regex -> 404. rest/search preservano
68
+ // l'encoding originale e vengono inoltrati verbatim.
69
+ function splitNodePath(afterNode) {
70
+ const qIdx = afterNode.indexOf('?');
71
+ const rawPath = qIdx >= 0 ? afterNode.slice(0, qIdx) : afterNode;
72
+ const search = qIdx >= 0 ? afterNode.slice(qIdx) : '';
73
+ const trimmed = rawPath.replace(/^\/+/, '');
74
+ if (!trimmed) return null; // '/node' o '/node/' senza name -> 404
75
+ const slash = trimmed.indexOf('/');
76
+ const name = slash >= 0 ? trimmed.slice(0, slash) : trimmed;
77
+ const rest = slash >= 0 ? trimmed.slice(slash) : '/';
78
+ return { name, rest, search };
79
+ }
80
+
81
+ // Proxy transitivo: il path inoltrato NON deve a sua volta essere un /node/*.
82
+ // Controlla sia raw sia decodificato (il remoto normalizza il percent-encoding
83
+ // in fase di routing, quindi '/%6eode/b' vale '/node/b').
84
+ function isTransitiveRest(rest) {
85
+ const hit = (p) => p === '/node' || p.startsWith('/node/');
86
+ if (hit(rest)) return true;
87
+ try { if (hit(decodeURIComponent(rest))) return true; } catch (_) { /* malformed: raw basta */ }
88
+ return false;
89
+ }
90
+
91
+ // Headers per l'HTTP upstream: strip client-supplied pericolosi, inietta il token
92
+ // del nodo. host lo mette node http.request da host/port (loopback da config).
93
+ function sanitizeRequestHeaders(headers, remoteToken) {
94
+ const out = {};
95
+ const dynamicHop = connectionOptions(headers);
96
+ for (const [k, v] of Object.entries(headers || {})) {
97
+ if (isStrippedRequestHeader(k.toLowerCase()) || dynamicHop.has(k.toLowerCase())) continue;
98
+ out[k] = v;
99
+ }
100
+ if (remoteToken) out.authorization = `Bearer ${remoteToken}`;
101
+ return out;
102
+ }
103
+
104
+ // Headers della RISPOSTA verso il browser: strip hop-by-hop (igiene proxy). Il
105
+ // token remoto non viene MAI messo qui dal proxy; il nodo remoto non lo riflette.
106
+ function sanitizeResponseHeaders(headers) {
107
+ const out = {};
108
+ const dynamicHop = connectionOptions(headers);
109
+ for (const [k, v] of Object.entries(headers || {})) {
110
+ if (HOP_BY_HOP.has(k.toLowerCase()) || dynamicHop.has(k.toLowerCase())) continue;
111
+ out[k] = v;
112
+ }
113
+ return out;
114
+ }
115
+
116
+ function notFound(res) { res.status(404).json({ error: 'not found' }); }
117
+
118
+ // --- HTTP proxy middleware --------------------------------------------------
119
+ // Montare come: app.use('/node', requireToken(token), createNodeProxy({...}))
120
+ // -> req.url e' gia' il path DOPO /node (es. '/vps/api/x'); l'auth locale e' gia'
121
+ // passata (requireToken davanti). Ordine interno: name strict -> no-transitive ->
122
+ // resolve -> readonly -> proxy.
123
+ function createNodeProxy(deps) {
124
+ const { resolveNode, readonly = () => false, httpRequest = http.request } = deps;
125
+ return function nodeProxy(req, res) {
126
+ const parsed = splitNodePath(req.url);
127
+ if (!parsed) return notFound(res); // §4b(2)#2 no name
128
+ if (!NODE_NAME_RE.test(parsed.name)) return notFound(res); // §4b(2)#2 strict/traversal
129
+ if (isTransitiveRest(parsed.rest)) return notFound(res); // §4b(2)#7 no transitive
130
+ const node = resolveNode(parsed.name);
131
+ if (!node) return notFound(res); // nome non in config -> 404 secco
132
+ if (readonly() && MUTATING.has(req.method)) {
133
+ return res.status(403).json({ error: 'READONLY: mutazione verso nodo bloccata' });
134
+ }
135
+ proxyHttp(req, res, node, parsed.rest, parsed.search, httpRequest);
136
+ };
137
+ }
138
+
139
+ function proxyHttp(req, res, node, rest, search, httpRequest) {
140
+ const options = {
141
+ host: '127.0.0.1', // §4b(2)#4 upstream SOLO loopback da config
142
+ port: node.localPort,
143
+ method: req.method,
144
+ // parita' col path WS: il token LOCALE eventualmente in query (?token=) non
145
+ // deve MAI arrivare al nodo remoto — l'auth upstream e' l'Authorization col
146
+ // token remoto iniettato da sanitizeRequestHeaders.
147
+ path: `${rest}${stripLocalTokenQuery(search)}`,
148
+ headers: sanitizeRequestHeaders(req.headers, node.token),
149
+ };
150
+ // http.request puo' lanciare in modo SINCRONO (es. header value con char
151
+ // invalido dal token del nodo) -> senza try/catch diventa un throw non gestito
152
+ // e Express emette una pagina 500 con lo stack (path interni). Chiudiamo in
153
+ // 502 JSON come gli altri errori upstream, senza esporre nulla. (fix audit).
154
+ let upstream;
155
+ try {
156
+ upstream = httpRequest(options, (up) => {
157
+ res.writeHead(up.statusCode, sanitizeResponseHeaders(up.headers));
158
+ up.pipe(res);
159
+ });
160
+ } catch (_) {
161
+ if (!res.headersSent) res.status(502).json({ error: 'node non raggiungibile' });
162
+ return;
163
+ }
164
+ upstream.setTimeout(PROXY_TIMEOUT_MS, () => upstream.destroy(new Error('upstream timeout')));
165
+ upstream.on('error', () => {
166
+ if (!res.headersSent) res.status(502).json({ error: 'node non raggiungibile' });
167
+ else res.destroy();
168
+ });
169
+ req.on('aborted', () => upstream.destroy());
170
+ req.pipe(upstream);
171
+ }
172
+
173
+ // --- WebSocket upgrade proxy (raw socket) -----------------------------------
174
+ // Stessi check dell'HTTP, ma l'auth locale va fatta in TESTA (browser non puo'
175
+ // settare Authorization sul WS -> token accettato anche via ?token=<local>).
176
+ // Proxy trasparente a livello TCP: si inoltra la stessa upgrade request (header
177
+ // sanificati + token remoto iniettato) all'upstream loopback e si fa piping raw
178
+ // dei due socket. Nessun doppio handshake: l'Accept lo calcola l'upstream dalla
179
+ // Sec-WebSocket-Key del client, che inoltriamo verbatim.
180
+ function bearerFromUpgrade(req, url) {
181
+ const h = bearerFrom(req);
182
+ if (h) return h;
183
+ try { return url.searchParams.get('token') || ''; } catch (_) { return ''; }
184
+ }
185
+
186
+ function abortUpgrade(socket, code) {
187
+ const msg = http.STATUS_CODES[code] || 'Error';
188
+ try {
189
+ socket.write(
190
+ `HTTP/1.1 ${code} ${msg}\r\n` +
191
+ 'Connection: close\r\n' +
192
+ 'Content-Type: text/plain\r\n' +
193
+ `Content-Length: ${Buffer.byteLength(msg)}\r\n\r\n${msg}`,
194
+ );
195
+ } catch (_) { /* socket gia' morto */ }
196
+ try { socket.destroy(); } catch (_) {}
197
+ }
198
+
199
+ // Costruisce il blocco header raw dell'upgrade da inoltrare upstream. Preserva i
200
+ // Sec-WebSocket-* e imposta Connection/Upgrade in modo controllato; strippa i
201
+ // client-supplied pericolosi; inietta il token remoto. Rimuove ?token= locale.
202
+ function buildUpgradeRequest(method, rest, search, headers, remoteToken, localPort) {
203
+ const lines = [];
204
+ const cleanSearch = stripLocalTokenQuery(search);
205
+ const clean = sanitizeRequestHeaders(headers, remoteToken);
206
+ lines.push(`${method} ${rest}${cleanSearch} HTTP/1.1`);
207
+ lines.push(`Host: 127.0.0.1:${localPort}`);
208
+ for (const [k, v] of Object.entries(clean)) {
209
+ const lk = k.toLowerCase();
210
+ if (lk === 'host' || lk === 'connection' || lk === 'upgrade') continue;
211
+ const val = Array.isArray(v) ? v.join(', ') : v;
212
+ lines.push(`${k}: ${val}`);
213
+ }
214
+ lines.push('Connection: Upgrade');
215
+ lines.push('Upgrade: websocket');
216
+ return `${lines.join('\r\n')}\r\n\r\n`;
217
+ }
218
+
219
+ function stripLocalTokenQuery(search) {
220
+ if (!search || search === '?') return '';
221
+ const sp = new URLSearchParams(search.slice(1));
222
+ sp.delete('token');
223
+ const s = sp.toString();
224
+ return s ? `?${s}` : '';
225
+ }
226
+
227
+ // handleNodeUpgrade — chiamato dal server.on('upgrade') per i path /node/*.
228
+ // connect(port) -> duplex verso 127.0.0.1:port (default net.connect), seam test.
229
+ function handleNodeUpgrade(ctx) {
230
+ const {
231
+ req, socket, head, resolveNode, verifyToken,
232
+ readonly = () => false, connect = defaultConnect, activeSockets = null,
233
+ } = ctx;
234
+ let url;
235
+ try { url = new URL(req.url, 'http://127.0.0.1'); } catch (_) { return abortUpgrade(socket, 400); }
236
+
237
+ // (1) AUTH LOCALE PRIMA DI TUTTO
238
+ if (!verifyToken(bearerFromUpgrade(req, url))) return abortUpgrade(socket, 401);
239
+
240
+ // (2) name strict + (7) no transitive
241
+ const afterNode = req.url.replace(/^\/node(?=\/|\?|$)/, '');
242
+ const parsed = splitNodePath(afterNode);
243
+ if (!parsed || !NODE_NAME_RE.test(parsed.name)) return abortUpgrade(socket, 404);
244
+ if (isTransitiveRest(parsed.rest)) return abortUpgrade(socket, 404);
245
+ const node = resolveNode(parsed.name);
246
+ if (!node) return abortUpgrade(socket, 404);
247
+
248
+ // §9d: in READONLY locale il WS proxy si nega in toto — il piping raw non puo'
249
+ // applicare un readonly frame-level e un attach WS e' un canale di scrittura
250
+ // (PTY remoto). Il nodo remoto applica il PROPRIO READONLY ai suoi client;
251
+ // qui vale quello locale, come per i metodi HTTP mutanti.
252
+ if (readonly()) return abortUpgrade(socket, 403);
253
+
254
+ // (3)(4) inject token remoto, upstream SOLO loopback da config
255
+ let upstream;
256
+ let settled = false;
257
+ let fail = (code) => { if (settled) return; settled = true; try { upstream && upstream.destroy(); } catch (_) {} abortUpgrade(socket, code); };
258
+ try {
259
+ upstream = connect(node.localPort); // net.connect puo' lanciare sync su opzioni invalide (port NaN da config)
260
+ } catch (_) { return fail(502); } // (parita' error-handling con proxyHttp: 502 JSON-equivalent, no stack)
261
+
262
+ const timer = setTimeout(() => fail(502), CONNECT_TIMEOUT_MS);
263
+ upstream.on('connect', () => {
264
+ clearTimeout(timer);
265
+ if (settled) { try { upstream.destroy(); } catch (_) {} return; }
266
+ // buildUpgradeRequest/write possono lanciare (header invalido). Settle DOPO il
267
+ // successo dei write: cosi' il catch -> fail(502) e' OPERATIVO (distrugge entrambi
268
+ // i socket e invia 502) invece di no-op. Prima dell'audit settled veniva messo a
269
+ // true PRIMA dei write: un throw li' rendeva fail() un no-op e lasciava entrambi i
270
+ // socket vivi (leak) senza alcun 502 al client (audit F5).
271
+ try {
272
+ upstream.write(buildUpgradeRequest(req.method, parsed.rest, parsed.search, req.headers, node.token, node.localPort));
273
+ if (head && head.length) upstream.write(head);
274
+ } catch (e) { return fail(502); }
275
+ settled = true;
276
+ if (activeSockets && typeof activeSockets.add === 'function') {
277
+ activeSockets.add(socket);
278
+ activeSockets.add(upstream);
279
+ const remove = () => {
280
+ try { activeSockets.delete(socket); } catch (_) {}
281
+ try { activeSockets.delete(upstream); } catch (_) {}
282
+ };
283
+ if (typeof socket.once === 'function') socket.once('close', remove);
284
+ if (typeof upstream.once === 'function') upstream.once('close', remove);
285
+ }
286
+ socket.on('error', () => { try { upstream.destroy(); } catch (_) {} });
287
+ upstream.on('error', () => { try { socket.destroy(); } catch (_) {} });
288
+ socket.pipe(upstream);
289
+ upstream.pipe(socket);
290
+ });
291
+ upstream.on('error', () => { clearTimeout(timer); fail(502); });
292
+ }
293
+
294
+ function defaultConnect(port) {
295
+ return net.connect({ host: '127.0.0.1', port });
296
+ }
297
+
298
+ module.exports = {
299
+ createNodeProxy,
300
+ handleNodeUpgrade,
301
+ // esposti per i test
302
+ splitNodePath, isTransitiveRest, sanitizeRequestHeaders, sanitizeResponseHeaders,
303
+ buildUpgradeRequest, stripLocalTokenQuery, isStrippedRequestHeader, connectionOptions, bearerFromUpgrade,
304
+ MUTATING, HOP_BY_HOP, PROXY_TIMEOUT_MS,
305
+ };
package/lib/pty/attach.js CHANGED
@@ -1,30 +1,55 @@
1
1
  'use strict';
2
+ const { execFile } = require('node:child_process');
3
+ const os = require('node:os');
2
4
  const { loadPty } = require('./provider.js');
3
5
 
4
6
  // Opens `tmux attach` inside a real PTY, non-destructive for other clients.
5
- // Size model (decisione DAG 2026-07-09, "segue il focus"): la sessione usa
6
- // `window-size latest` (impostato dal bridge) e i client web NON readonly
7
- // PARTECIPANO alla geometria chi è attivo per ultimo comanda. Tornare sul
8
- // desktop e digitare riporta la sessione grande; il telefono la restringe
9
- // solo mentre lo usi. I viewer readonly restano ignore-size (mai guidare).
10
- // readonly `-f read-only,ignore-size` (read-only limita anche copy-mode/KeyBar)
7
+ //
8
+ // Size model (emendamento post-audit §5b deck multi-finestra): la sessione usa
9
+ // `window-size latest` (impostato dal bridge). Un client "possiede" la geometria
10
+ // SOLO se NON è `ignore-size`. I tile grid/deck attaccano `ignore-size` (non
11
+ // contendono la geometria da N finestre/N ResizeObserver); l'owner è il tile col
12
+ // FOCUS, promosso a runtime via `refresh-client -f '!ignore-size'` (demozione col
13
+ // flag `ignore-size`). La single-view / gli attach diretti restano owner di
14
+ // default (takeSize non specificato → drive).
15
+ // - takeSize esplicito vince sempre (true = owner, false = ignore-size)
16
+ // - readonly ⇒ SEMPRE ignore-size (un viewer non guida mai)
17
+ // - readonly → `-f read-only,ignore-size` (read-only limita anche copy-mode/KeyBar)
11
18
  function openAttach(session, opts = {}) {
12
- const { readonly = false, takeSize = false, cols = 80, rows = 24, tmuxBin = 'tmux' } = opts;
19
+ const { readonly = false, cols = 80, rows = 24, tmuxBin = 'tmux' } = opts;
20
+ const drives = opts.takeSize !== undefined
21
+ ? !!opts.takeSize
22
+ : (opts.ignoreSize !== undefined ? !opts.ignoreSize : true);
23
+ const ignoreSize = readonly || !drives;
13
24
  const pty = loadPty();
14
25
  const args = ['attach-session', '-t', session];
15
- if (readonly) args.push('-f', 'read-only,ignore-size');
26
+ const flags = [];
27
+ if (readonly) flags.push('read-only');
28
+ if (ignoreSize) flags.push('ignore-size');
29
+ if (flags.length) args.push('-f', flags.join(','));
16
30
  const term = pty.spawn(tmuxBin, args, {
17
31
  name: 'xterm-256color',
18
32
  cols, rows,
19
- cwd: process.env.HOME,
33
+ cwd: process.env.HOME || os.homedir(),
20
34
  env: process.env,
21
35
  });
36
+ // tty del client tmux (es. /dev/pts/N): identifica QUESTO client per la
37
+ // promozione/demozione runtime del size-owner via `refresh-client -t <tty>`.
38
+ const tty = term.ptsName || term._pty || null;
39
+ const setFlags = (spec) => {
40
+ if (!tty) return;
41
+ try { execFile(tmuxBin, ['refresh-client', '-t', tty, '-f', spec], () => {}); } catch (_) {}
42
+ };
22
43
  return {
44
+ tty,
23
45
  write: (data) => term.write(data),
24
46
  resize: (c, r) => { try { term.resize(c, r); } catch (_) {} },
25
47
  onData: (cb) => term.onData(cb),
26
48
  onExit: (cb) => term.onExit(cb),
27
49
  kill: () => { try { term.kill(); } catch (_) {} },
50
+ // Promozione a size-owner (focus). I readonly non guidano MAI la geometria.
51
+ promote: () => { if (!readonly) setFlags('!ignore-size'); },
52
+ demote: () => setFlags('ignore-size'),
28
53
  };
29
54
  }
30
55
  module.exports = { openAttach };
@@ -4,19 +4,34 @@
4
4
  // `tmux attach` ESIGE un vero tty: il fallback child_process NON è accettabile qui.
5
5
  let _cached = null;
6
6
 
7
+ function providerCandidates({ platform = process.platform, arch = process.arch, env = process.env } = {}) {
8
+ const isTermux = platform === 'android'
9
+ || (env.PREFIX || '').includes('com.termux');
10
+ if (isTermux) return ['@mmmbuto/node-pty-android-arm64', 'node-pty'];
11
+ if (platform === 'darwin') {
12
+ return arch === 'arm64'
13
+ ? ['@lydell/node-pty-darwin-arm64', 'node-pty']
14
+ : ['@lydell/node-pty-darwin-x64', 'node-pty'];
15
+ }
16
+ if (platform === 'linux' && arch === 'arm64') {
17
+ return ['@lydell/node-pty-linux-arm64', 'node-pty'];
18
+ }
19
+ return ['@lydell/node-pty-linux-x64', 'node-pty'];
20
+ }
21
+
7
22
  function loadPty() {
8
23
  if (_cached) return _cached;
9
24
  const isTermux = process.platform === 'android'
10
25
  || (process.env.PREFIX || '').includes('com.termux');
11
- const candidates = isTermux
12
- ? ['@mmmbuto/node-pty-android-arm64', 'node-pty']
13
- : ['node-pty', '@lydell/node-pty-linux-x64', '@mmmbuto/node-pty-android-arm64'];
26
+ const candidates = providerCandidates();
14
27
  for (const mod of candidates) {
15
28
  try {
16
29
  const pty = require(mod);
17
30
  // Accept ONLY a provider with a complete PTY API; never child_process.
18
31
  if (pty && typeof pty.spawn === 'function') {
19
- const t = pty.spawn(process.env.SHELL || '/bin/sh', ['-c', 'true'], { cols: 80, rows: 24 });
32
+ const fallbackShell = isTermux && process.env.PREFIX
33
+ ? require('node:path').join(process.env.PREFIX, 'bin', 'sh') : '/bin/sh';
34
+ const t = pty.spawn(process.env.SHELL || fallbackShell, ['-c', 'true'], { cols: 80, rows: 24 });
20
35
  const ok = t && typeof t.write === 'function' && typeof t.onData === 'function'
21
36
  && typeof t.resize === 'function' && typeof t.kill === 'function';
22
37
  try { t.kill(); } catch (_) {}
@@ -28,6 +43,6 @@ function loadPty() {
28
43
  }
29
44
  } catch (_) { /* prova il prossimo */ }
30
45
  }
31
- throw new Error('no real PTY provider available (need node-pty or @mmmbuto/node-pty-android-arm64)');
46
+ throw new Error('no real PTY provider available (install a platform prebuilt or node-pty build prerequisites)');
32
47
  }
33
- module.exports = { loadPty };
48
+ module.exports = { loadPty, providerCandidates };