190 lines
6.6 KiB
JavaScript
190 lines
6.6 KiB
JavaScript
/**
|
|
* Raw byte tunnel to a dev server running on the OpenChamber host.
|
|
*
|
|
* This is what lets a desktop client preview a dev server that lives on another
|
|
* machine without rewriting anything. The client binds its own local port and
|
|
* pipes it here; the page is then served from a real origin at the root of its
|
|
* own host, so absolute URLs, cookies, HMR sockets, and DevTools all behave
|
|
* exactly as they do locally. No HTML is inspected or modified.
|
|
*
|
|
* Security posture: the reachable set is the same list dev-server discovery
|
|
* offers the user, not "any loopback port". Without that restriction an
|
|
* authenticated client could dial arbitrary local services on the host —
|
|
* databases, admin panels, the OpenCode API — through this socket.
|
|
*
|
|
* Authentication differs from the browser-facing sockets on purpose. Those
|
|
* demand an allowed `Origin`, which is a CSRF defence: a hostile page can make
|
|
* a browser open a WebSocket carrying the user's ambient cookies, and the
|
|
* origin is what exposes it. This tunnel's client is the desktop shell, not a
|
|
* browser, and it authenticates with an explicit bearer token. So:
|
|
*
|
|
* - With an `Origin` header, the request came from a browser context and the
|
|
* usual origin check applies unchanged.
|
|
* - With no `Origin`, the request must carry client-token auth or a short-lived
|
|
* URL token. The URL-token case is used only by the trusted renderer through
|
|
* the E2EE relay; the UI-auth allowlist limits it to this exact path.
|
|
*/
|
|
import net from 'node:net';
|
|
import { WebSocketServer } from 'ws';
|
|
|
|
const DEV_TUNNEL_WS_PATH = '/api/dev-tunnel';
|
|
/** One page load opens many sockets; the cap is per host, not per page. */
|
|
const MAX_CONCURRENT_SOCKETS = 64;
|
|
const CONNECT_TIMEOUT_MS = 5_000;
|
|
|
|
const parseRequestedPort = (url) => {
|
|
try {
|
|
const parsed = new URL(String(url || ''), 'http://localhost');
|
|
if (parsed.pathname !== DEV_TUNNEL_WS_PATH) return null;
|
|
const port = Number.parseInt(parsed.searchParams.get('port') || '', 10);
|
|
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
export const isDevTunnelPath = (url) => {
|
|
try {
|
|
return new URL(String(url || ''), 'http://localhost').pathname === DEV_TUNNEL_WS_PATH;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
export function createDevTunnelRuntime({
|
|
server,
|
|
discoverDevServers,
|
|
uiAuthController,
|
|
isRequestOriginAllowed,
|
|
rejectWebSocketUpgrade,
|
|
logger = console,
|
|
}) {
|
|
const wsServer = new WebSocketServer({ noServer: true });
|
|
let openSockets = 0;
|
|
|
|
/**
|
|
* A port is reachable only while discovery still reports it. Re-checked on
|
|
* every upgrade rather than cached, so a dev server that stops listening
|
|
* stops being reachable.
|
|
*/
|
|
const isAllowedPort = async (port) => {
|
|
const result = await discoverDevServers();
|
|
if (!result?.ok) return false;
|
|
return result.servers.some((entry) => entry.port === port);
|
|
};
|
|
|
|
wsServer.on('connection', (socket, req) => {
|
|
const port = parseRequestedPort(req.url);
|
|
if (port === null) {
|
|
socket.close(1008, 'Invalid port');
|
|
return;
|
|
}
|
|
|
|
openSockets += 1;
|
|
const upstream = net.connect({ host: '127.0.0.1', port });
|
|
upstream.setNoDelay(true);
|
|
|
|
let settled = false;
|
|
const teardown = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
openSockets -= 1;
|
|
try { upstream.destroy(); } catch { /* already gone */ }
|
|
try { socket.close(); } catch { /* already closing */ }
|
|
};
|
|
|
|
const connectTimer = setTimeout(() => {
|
|
if (!upstream.connecting) return;
|
|
logger.warn?.(`[dev-tunnel] timed out connecting to 127.0.0.1:${port}`);
|
|
teardown();
|
|
}, CONNECT_TIMEOUT_MS);
|
|
|
|
upstream.on('connect', () => clearTimeout(connectTimer));
|
|
upstream.on('data', (chunk) => {
|
|
if (socket.readyState !== socket.OPEN) return;
|
|
socket.send(chunk);
|
|
// Stop reading from the dev server while the socket drains, otherwise a
|
|
// fast response against a slow client buffers the whole body in memory.
|
|
if (socket.bufferedAmount > 1_000_000) {
|
|
upstream.pause();
|
|
const resume = () => {
|
|
if (socket.bufferedAmount > 1_000_000) {
|
|
setTimeout(resume, 20);
|
|
return;
|
|
}
|
|
upstream.resume();
|
|
};
|
|
setTimeout(resume, 20);
|
|
}
|
|
});
|
|
upstream.on('error', () => { clearTimeout(connectTimer); teardown(); });
|
|
upstream.on('close', () => { clearTimeout(connectTimer); teardown(); });
|
|
|
|
socket.on('message', (data) => {
|
|
if (upstream.destroyed) return;
|
|
upstream.write(data);
|
|
});
|
|
socket.on('close', teardown);
|
|
socket.on('error', teardown);
|
|
});
|
|
|
|
const upgradeHandler = (req, socket, head) => {
|
|
if (!isDevTunnelPath(req.url)) return;
|
|
void (async () => {
|
|
try {
|
|
if (uiAuthController?.enabled) {
|
|
const auth = await uiAuthController.resolveAuthContext(req, null, { allowUrlToken: true });
|
|
if (!auth) {
|
|
rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
|
|
return;
|
|
}
|
|
const hasOrigin = typeof req.headers?.origin === 'string' && req.headers.origin.trim() !== '';
|
|
if (hasOrigin) {
|
|
if (!await isRequestOriginAllowed(req)) {
|
|
rejectWebSocketUpgrade(socket, 403, 'Invalid origin');
|
|
return;
|
|
}
|
|
} else if (auth.type !== 'client') {
|
|
rejectWebSocketUpgrade(socket, 403, 'Client authentication required');
|
|
return;
|
|
}
|
|
}
|
|
|
|
const port = parseRequestedPort(req.url);
|
|
if (port === null) {
|
|
rejectWebSocketUpgrade(socket, 400, 'Invalid port');
|
|
return;
|
|
}
|
|
if (openSockets >= MAX_CONCURRENT_SOCKETS) {
|
|
rejectWebSocketUpgrade(socket, 503, 'Too many tunnel connections');
|
|
return;
|
|
}
|
|
if (!await isAllowedPort(port)) {
|
|
// Says which port, because the alternative is an empty response in
|
|
// the panel with nothing anywhere explaining why.
|
|
logger.warn?.(`[dev-tunnel] refused port ${port}: not reported by dev-server discovery`);
|
|
rejectWebSocketUpgrade(socket, 403, 'That port is not an available dev server');
|
|
return;
|
|
}
|
|
|
|
wsServer.handleUpgrade(req, socket, head, (ws) => wsServer.emit('connection', ws, req));
|
|
} catch {
|
|
rejectWebSocketUpgrade(socket, 500, 'Upgrade failed');
|
|
}
|
|
})();
|
|
};
|
|
|
|
server.on('upgrade', upgradeHandler);
|
|
|
|
return {
|
|
path: DEV_TUNNEL_WS_PATH,
|
|
get openSocketCount() {
|
|
return openSockets;
|
|
},
|
|
dispose() {
|
|
server.off('upgrade', upgradeHandler);
|
|
wsServer.close();
|
|
},
|
|
};
|
|
}
|