@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,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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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,
|
|
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
|
-
|
|
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 };
|
package/lib/pty/provider.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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 (
|
|
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 };
|