fix: support dev server previews over relay
This commit is contained in:
@@ -155,6 +155,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u
|
|||||||
- SSH host import, connections, logs, and port forwarding.
|
- SSH host import, connections, logs, and port forwarding.
|
||||||
- SSH uses OpenSSH ControlMaster on macOS/Linux. Windows uses independent hidden OpenSSH processes for setup commands and each long-lived forward because Win32 OpenSSH does not support ControlMaster reliably.
|
- SSH uses OpenSSH ControlMaster on macOS/Linux. Windows uses independent hidden OpenSSH processes for setup commands and each long-lived forward because Win32 OpenSSH does not support ControlMaster reliably.
|
||||||
- Tunnel lifecycle integration through the web server runtime.
|
- Tunnel lifecycle integration through the web server runtime.
|
||||||
|
- Remote dev-server previews use a direct WebSocket tunnel when the instance has an HTTP address. Relay-only instances keep the encrypted relay transport in the renderer and bridge its raw bytes to the browser panel through a local Electron listener.
|
||||||
- Auto-update checks, downloads, and restart/apply flow.
|
- Auto-update checks, downloads, and restart/apply flow.
|
||||||
- The browser panel's own session (`persist:openchamber-browser`): its storage is
|
- The browser panel's own session (`persist:openchamber-browser`): its storage is
|
||||||
cleared only through the scoped clear-data command, and camera, microphone,
|
cleared only through the scoped clear-data command, and camera, microphone,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, net as electronNet, Notification, powerMonitor, powerSaveBlocker, protocol, screen, session, shell, webContents } from 'electron';
|
import { app, BrowserWindow, dialog, ipcMain, Menu, MessageChannelMain, nativeTheme, net as electronNet, Notification, powerMonitor, powerSaveBlocker, protocol, screen, session, shell, webContents } from 'electron';
|
||||||
import contextMenu from 'electron-context-menu';
|
import contextMenu from 'electron-context-menu';
|
||||||
import log from 'electron-log/main.js';
|
import log from 'electron-log/main.js';
|
||||||
import dgram from 'node:dgram';
|
import dgram from 'node:dgram';
|
||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
} from './linux-autostart.mjs';
|
} from './linux-autostart.mjs';
|
||||||
import { unsupportedAppSpecificOpenError, validateLocalPath } from './path-open-utils.mjs';
|
import { unsupportedAppSpecificOpenError, validateLocalPath } from './path-open-utils.mjs';
|
||||||
import { shouldAllowBrowserPanelCertificateError } from './browser-panel-security.mjs';
|
import { shouldAllowBrowserPanelCertificateError } from './browser-panel-security.mjs';
|
||||||
|
import { createRelayDevTunnelBridge } from './relay-dev-tunnel.mjs';
|
||||||
import { attachRendererRecovery } from './renderer-recovery.mjs';
|
import { attachRendererRecovery } from './renderer-recovery.mjs';
|
||||||
import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js';
|
import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js';
|
||||||
|
|
||||||
@@ -3839,6 +3840,7 @@ const runSpecChain = (specs, appName) => {
|
|||||||
// The tunnel client lives in the web package (it already has a WebSocket
|
// The tunnel client lives in the web package (it already has a WebSocket
|
||||||
// client) and is loaded only if the user actually previews a remote dev server.
|
// client) and is loaded only if the user actually previews a remote dev server.
|
||||||
let devTunnelClientPromise = null;
|
let devTunnelClientPromise = null;
|
||||||
|
const relayDevTunnelBridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannelMain(), logger: log });
|
||||||
const getDevTunnelClient = async () => {
|
const getDevTunnelClient = async () => {
|
||||||
if (!devTunnelClientPromise) {
|
if (!devTunnelClientPromise) {
|
||||||
devTunnelClientPromise = import('@openchamber/web/server/lib/dev-tunnel/client.js')
|
devTunnelClientPromise = import('@openchamber/web/server/lib/dev-tunnel/client.js')
|
||||||
@@ -3852,6 +3854,7 @@ const getDevTunnelClient = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const closeAllDevTunnels = () => {
|
const closeAllDevTunnels = () => {
|
||||||
|
relayDevTunnelBridge.closeAll();
|
||||||
if (!devTunnelClientPromise) return;
|
if (!devTunnelClientPromise) return;
|
||||||
const pending = devTunnelClientPromise;
|
const pending = devTunnelClientPromise;
|
||||||
devTunnelClientPromise = null;
|
devTunnelClientPromise = null;
|
||||||
@@ -3959,6 +3962,11 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
|||||||
if (!baseUrl) throw new Error('baseUrl is required');
|
if (!baseUrl) throw new Error('baseUrl is required');
|
||||||
if (!(port > 0 && port <= 65535)) throw new Error('A valid port is required');
|
if (!(port > 0 && port <= 65535)) throw new Error('A valid port is required');
|
||||||
|
|
||||||
|
if (args.relay === true) {
|
||||||
|
const targetKey = typeof args.targetKey === 'string' ? args.targetKey.trim() : '';
|
||||||
|
return relayDevTunnelBridge.open({ targetKey, remotePort: port, webContents: browserWindow?.webContents });
|
||||||
|
}
|
||||||
|
|
||||||
const headers = {};
|
const headers = {};
|
||||||
const requestHeaders = args.requestHeaders && typeof args.requestHeaders === 'object' ? args.requestHeaders : {};
|
const requestHeaders = args.requestHeaders && typeof args.requestHeaders === 'object' ? args.requestHeaders : {};
|
||||||
for (const [name, value] of Object.entries(requestHeaders)) {
|
for (const [name, value] of Object.entries(requestHeaders)) {
|
||||||
@@ -3981,6 +3989,9 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
|||||||
return { closed: client.close({ baseUrl, port }) };
|
return { closed: client.close({ baseUrl, port }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'desktop_relay_dev_tunnel_close_all':
|
||||||
|
return { closed: relayDevTunnelBridge.closeForWebContents(browserWindow?.webContents.id) };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forces prefers-color-scheme for one previewed page.
|
* Forces prefers-color-scheme for one previewed page.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -158,14 +158,41 @@ ipcRenderer.on('openchamber:emit', (_evt, payload) => {
|
|||||||
dispatchNativeEvent(event, payload.detail);
|
dispatchNativeEvent(event, payload.detail);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const relayDevTunnelPorts = new Map();
|
||||||
|
let relayDevTunnelHandler = null;
|
||||||
|
ipcRenderer.on('openchamber:relay-dev-tunnel-connect', (event, payload) => {
|
||||||
|
if (!isLocalPage || !payload || typeof payload.connectionId !== 'string' || !event.ports?.[0]) return;
|
||||||
|
const port = event.ports[0];
|
||||||
|
relayDevTunnelPorts.set(payload.connectionId, port);
|
||||||
|
port.onmessage = (messageEvent) => relayDevTunnelHandler?.({
|
||||||
|
connectionId: payload.connectionId,
|
||||||
|
remotePort: payload.remotePort,
|
||||||
|
message: messageEvent.data,
|
||||||
|
});
|
||||||
|
port.start();
|
||||||
|
relayDevTunnelHandler?.({ connectionId: payload.connectionId, remotePort: payload.remotePort, message: { type: 'connect' } });
|
||||||
|
});
|
||||||
|
|
||||||
// The desktop bridge is exposed on all pages; the main-process gate in
|
// The desktop bridge is exposed on all pages; the main-process gate in
|
||||||
// ipcMain.handle('openchamber:invoke') decides per-command what is safe
|
// ipcMain.handle('openchamber:invoke') decides per-command what is safe
|
||||||
// for non-local callers (window/host-switcher ops yes, file/shell ops
|
// for non-local callers (window/host-switcher ops yes, file/shell ops
|
||||||
// no). See COMMANDS_SAFE_FOR_REMOTE in main.mjs.
|
// no). See COMMANDS_SAFE_FOR_REMOTE in main.mjs.
|
||||||
contextBridge.exposeInMainWorld('__OPENCHAMBER_DESKTOP__', {
|
const desktopBridge = {
|
||||||
invoke: (cmd, args) => ipcRenderer.invoke('openchamber:invoke', cmd, args || {}),
|
invoke: (cmd, args) => ipcRenderer.invoke('openchamber:invoke', cmd, args || {}),
|
||||||
openDialog: (options) => ipcRenderer.invoke('openchamber:dialog:open', options || {}),
|
openDialog: (options) => ipcRenderer.invoke('openchamber:dialog:open', options || {}),
|
||||||
grantFileAccess: (filePath) => ipcRenderer.invoke('openchamber:file:grant-existing', filePath),
|
grantFileAccess: (filePath) => ipcRenderer.invoke('openchamber:file:grant-existing', filePath),
|
||||||
openExternal: (url) => ipcRenderer.invoke('openchamber:invoke', 'desktop_open_external_url', { url }),
|
openExternal: (url) => ipcRenderer.invoke('openchamber:invoke', 'desktop_open_external_url', { url }),
|
||||||
listen: async (event, handler) => addListener(event, handler),
|
listen: async (event, handler) => addListener(event, handler),
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (isLocalPage) {
|
||||||
|
desktopBridge.relayDevTunnelListen = (handler) => {
|
||||||
|
relayDevTunnelHandler = typeof handler === 'function' ? handler : null;
|
||||||
|
};
|
||||||
|
desktopBridge.relayDevTunnelPost = (connectionId, message) => {
|
||||||
|
relayDevTunnelPorts.get(connectionId)?.postMessage(message);
|
||||||
|
if (message?.type === 'close') relayDevTunnelPorts.delete(connectionId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('__OPENCHAMBER_DESKTOP__', desktopBridge);
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import net from 'node:net';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
const CONNECTION_READY_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
const listen = (server) => new Promise((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
server.off('error', reject);
|
||||||
|
const address = server.address();
|
||||||
|
const port = Number(address?.port);
|
||||||
|
if (!Number.isInteger(port) || port <= 0) {
|
||||||
|
reject(new Error('Failed to bind a local relay tunnel port'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageData = (event) => {
|
||||||
|
if (event?.type === 'ready' || event?.type === 'data' || event?.type === 'close') return event;
|
||||||
|
return event?.data ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRelayDevTunnelBridge = ({ createMessageChannel, logger = console } = {}) => {
|
||||||
|
const tunnels = new Map();
|
||||||
|
|
||||||
|
const closeTunnel = (key) => {
|
||||||
|
const tunnel = tunnels.get(key);
|
||||||
|
if (!tunnel) return false;
|
||||||
|
tunnels.delete(key);
|
||||||
|
for (const connection of tunnel.connections.values()) connection.close();
|
||||||
|
try { tunnel.server.close(); } catch { /* already closing */ }
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
async open({ targetKey, remotePort, webContents }) {
|
||||||
|
const port = Number.parseInt(String(remotePort), 10);
|
||||||
|
if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error('A valid remote port is required');
|
||||||
|
if (!targetKey) throw new Error('A relay target key is required');
|
||||||
|
if (!webContents || webContents.isDestroyed?.()) throw new Error('The desktop window is unavailable');
|
||||||
|
|
||||||
|
const key = `${webContents.id}|${targetKey}|${port}`;
|
||||||
|
const existing = tunnels.get(key);
|
||||||
|
if (existing) return { localPort: existing.localPort, reused: true };
|
||||||
|
|
||||||
|
const connections = new Map();
|
||||||
|
const server = net.createServer((socket) => {
|
||||||
|
socket.setNoDelay(true);
|
||||||
|
socket.pause();
|
||||||
|
const connectionId = randomUUID();
|
||||||
|
const { port1, port2 } = createMessageChannel();
|
||||||
|
let closed = false;
|
||||||
|
const readyTimer = setTimeout(() => close(), CONNECTION_READY_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
clearTimeout(readyTimer);
|
||||||
|
connections.delete(connectionId);
|
||||||
|
try { port1.postMessage({ type: 'close' }); } catch { /* already closed */ }
|
||||||
|
try { socket.destroy(); } catch { /* already closed */ }
|
||||||
|
try { port1.close(); } catch { /* already closed */ }
|
||||||
|
};
|
||||||
|
connections.set(connectionId, { close });
|
||||||
|
|
||||||
|
port1.on('message', (event) => {
|
||||||
|
const message = messageData(event);
|
||||||
|
if (!message) return;
|
||||||
|
if (message.type === 'ready') {
|
||||||
|
clearTimeout(readyTimer);
|
||||||
|
socket.resume();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.type === 'data' && message.data) {
|
||||||
|
socket.write(Buffer.from(message.data));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.type === 'close') close();
|
||||||
|
});
|
||||||
|
port1.on('close', close);
|
||||||
|
port1.start?.();
|
||||||
|
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
if (closed) return;
|
||||||
|
port1.postMessage({ type: 'data', data: Uint8Array.from(chunk) });
|
||||||
|
});
|
||||||
|
socket.on('error', close);
|
||||||
|
socket.on('close', close);
|
||||||
|
|
||||||
|
try {
|
||||||
|
webContents.postMessage('openchamber:relay-dev-tunnel-connect', { connectionId, remotePort: port }, [port2]);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn?.(`[dev-tunnel] failed to hand relay connection to renderer: ${error?.message || error}`);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const localPort = await listen(server);
|
||||||
|
server.on('error', (error) => logger.warn?.(`[dev-tunnel] relay listener failed: ${error?.message || error}`));
|
||||||
|
tunnels.set(key, { server, connections, localPort });
|
||||||
|
webContents.once?.('destroyed', () => closeTunnel(key));
|
||||||
|
return { localPort, reused: false };
|
||||||
|
},
|
||||||
|
|
||||||
|
closeAll() {
|
||||||
|
for (const key of [...tunnels.keys()]) closeTunnel(key);
|
||||||
|
},
|
||||||
|
|
||||||
|
closeForWebContents(webContentsId) {
|
||||||
|
let closed = 0;
|
||||||
|
const prefix = `${webContentsId}|`;
|
||||||
|
for (const key of [...tunnels.keys()]) {
|
||||||
|
if (!key.startsWith(prefix)) continue;
|
||||||
|
if (closeTunnel(key)) closed += 1;
|
||||||
|
}
|
||||||
|
return closed;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { afterEach, describe, expect, test } from 'bun:test';
|
||||||
|
import net from 'node:net';
|
||||||
|
import { MessageChannel } from 'node:worker_threads';
|
||||||
|
import { createRelayDevTunnelBridge } from './relay-dev-tunnel.mjs';
|
||||||
|
|
||||||
|
const bridges = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (bridges.length) bridges.pop().closeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('relay dev tunnel bridge', () => {
|
||||||
|
test('pipes a local browser connection through a renderer-owned message port', async () => {
|
||||||
|
let nextPort;
|
||||||
|
const webContents = {
|
||||||
|
id: 7,
|
||||||
|
isDestroyed: () => false,
|
||||||
|
once: () => {},
|
||||||
|
postMessage: (_channel, payload, ports) => {
|
||||||
|
nextPort = ports[0];
|
||||||
|
nextPort.on('message', (message) => {
|
||||||
|
if (message.type !== 'data') return;
|
||||||
|
expect(Buffer.from(message.data).toString()).toContain('GET /docs HTTP/1.1');
|
||||||
|
nextPort.postMessage({ type: 'data', data: Buffer.from('HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok') });
|
||||||
|
nextPort.postMessage({ type: 'close' });
|
||||||
|
});
|
||||||
|
nextPort.start();
|
||||||
|
expect(payload.remotePort).toBe(4322);
|
||||||
|
nextPort.postMessage({ type: 'ready' });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const bridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannel(), logger: { warn: () => {} } });
|
||||||
|
bridges.push(bridge);
|
||||||
|
const { localPort } = await bridge.open({ targetKey: 'host:exe', remotePort: 4322, webContents });
|
||||||
|
|
||||||
|
const response = await new Promise((resolve, reject) => {
|
||||||
|
const socket = net.connect({ host: '127.0.0.1', port: localPort }, () => socket.write('GET /docs HTTP/1.1\r\nHost: localhost\r\n\r\n'));
|
||||||
|
let data = '';
|
||||||
|
socket.on('data', (chunk) => { data += chunk; });
|
||||||
|
socket.on('close', () => resolve(data));
|
||||||
|
socket.on('error', reject);
|
||||||
|
});
|
||||||
|
expect(response).toContain('\r\n\r\nok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reuses one local listener for the same window, runtime, and port', async () => {
|
||||||
|
const webContents = { id: 9, isDestroyed: () => false, once: () => {}, postMessage: () => {} };
|
||||||
|
const bridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannel() });
|
||||||
|
bridges.push(bridge);
|
||||||
|
const first = await bridge.open({ targetKey: 'host:exe', remotePort: 4322, webContents });
|
||||||
|
const second = await bridge.open({ targetKey: 'host:exe', remotePort: 4322, webContents });
|
||||||
|
expect(second).toEqual({ localPort: first.localPort, reused: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tells the renderer when the local browser connection closes', async () => {
|
||||||
|
const rendererClosed = new Promise((resolve) => {
|
||||||
|
const webContents = {
|
||||||
|
id: 11,
|
||||||
|
isDestroyed: () => false,
|
||||||
|
once: () => {},
|
||||||
|
postMessage: (_channel, _payload, ports) => {
|
||||||
|
const rendererPort = ports[0];
|
||||||
|
rendererPort.on('message', (message) => {
|
||||||
|
if (message.type === 'close') resolve();
|
||||||
|
});
|
||||||
|
rendererPort.start();
|
||||||
|
rendererPort.postMessage({ type: 'ready' });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const bridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannel() });
|
||||||
|
bridges.push(bridge);
|
||||||
|
void bridge.open({ targetKey: 'host:exe', remotePort: 4322, webContents }).then(({ localPort }) => {
|
||||||
|
const socket = net.connect({ host: '127.0.0.1', port: localPort }, () => socket.destroy());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await rendererClosed;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closes only listeners owned by the requested desktop window', async () => {
|
||||||
|
const bridge = createRelayDevTunnelBridge({ createMessageChannel: () => new MessageChannel() });
|
||||||
|
bridges.push(bridge);
|
||||||
|
const windowOne = { id: 21, isDestroyed: () => false, once: () => {}, postMessage: () => {} };
|
||||||
|
const windowTwo = { id: 22, isDestroyed: () => false, once: () => {}, postMessage: () => {} };
|
||||||
|
const first = await bridge.open({ targetKey: 'host:one', remotePort: 4322, webContents: windowOne });
|
||||||
|
const second = await bridge.open({ targetKey: 'host:two', remotePort: 4322, webContents: windowTwo });
|
||||||
|
|
||||||
|
expect(bridge.closeForWebContents(windowOne.id)).toBe(1);
|
||||||
|
await expect(new Promise((resolve, reject) => {
|
||||||
|
const socket = net.connect({ host: '127.0.0.1', port: first.localPort }, resolve);
|
||||||
|
socket.on('error', reject);
|
||||||
|
})).rejects.toThrow();
|
||||||
|
const remaining = await bridge.open({ targetKey: 'host:two', remotePort: 4322, webContents: windowTwo });
|
||||||
|
expect(remaining).toEqual({ localPort: second.localPort, reused: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,19 +2,52 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|||||||
|
|
||||||
let apiBaseUrl = 'https://remote.example.test';
|
let apiBaseUrl = 'https://remote.example.test';
|
||||||
|
|
||||||
let tunnelResult: unknown = { localPort: 52418, reused: false };
|
type TunnelResult = { localPort: number; reused: boolean } | Error;
|
||||||
|
type DesktopTunnelArgs = { baseUrl?: string; port?: number; relay?: boolean; targetKey?: string };
|
||||||
|
type RelayEvent = { connectionId: string; remotePort: number; message: { type: string; data?: ArrayBuffer } };
|
||||||
|
type RelaySocketFixture = {
|
||||||
|
binaryType: string;
|
||||||
|
onopen: (() => void) | null;
|
||||||
|
onmessage: ((event: { data: ArrayBuffer | string }) => void) | null;
|
||||||
|
onerror: (() => void) | null;
|
||||||
|
onclose: (() => void) | null;
|
||||||
|
send: ReturnType<typeof mock>;
|
||||||
|
close: ReturnType<typeof mock>;
|
||||||
|
readyState: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let tunnelResult: TunnelResult = { localPort: 52418, reused: false };
|
||||||
|
let desktopArgs: DesktopTunnelArgs | undefined;
|
||||||
|
let relayActive = false;
|
||||||
|
let openedRelayUrl = '';
|
||||||
|
let refreshedBaseUrl = '';
|
||||||
|
let refreshUrlAuth = async (baseUrl: string) => { refreshedBaseUrl = baseUrl; return 'url-token'; };
|
||||||
|
let relayHandler: ((event: RelayEvent) => void) | null = null;
|
||||||
|
const relayPosts: Array<{ connectionId: string; message: { type: string; data?: ArrayBuffer } }> = [];
|
||||||
|
const relaySocket: RelaySocketFixture = { binaryType: 'arraybuffer', onopen: null, onmessage: null, onerror: null, onclose: null, send: mock(() => {}), close: mock(() => {}), readyState: 0 };
|
||||||
mock.module('@/lib/desktopNative', () => ({
|
mock.module('@/lib/desktopNative', () => ({
|
||||||
invokeDesktopCommand: mock(async () => {
|
invokeDesktopCommand: mock(async (_command: string, args?: DesktopTunnelArgs) => {
|
||||||
|
desktopArgs = args;
|
||||||
if (tunnelResult instanceof Error) throw tunnelResult;
|
if (tunnelResult instanceof Error) throw tunnelResult;
|
||||||
return tunnelResult;
|
return tunnelResult;
|
||||||
}),
|
}),
|
||||||
|
listenForDesktopRelayDevTunnels: (handler: typeof relayHandler) => { relayHandler = handler; return true; },
|
||||||
|
postDesktopRelayDevTunnelMessage: (connectionId: string, message: { type: string; data?: ArrayBuffer }) => relayPosts.push({ connectionId, message }),
|
||||||
}));
|
}));
|
||||||
|
mock.module('@/lib/relay/runtime-tunnel', () => ({
|
||||||
|
isRelayModeActive: () => relayActive,
|
||||||
|
getActiveRelayTunnel: () => relayActive ? {} : null,
|
||||||
|
}));
|
||||||
|
mock.module('@/lib/relay/runtime-socket', () => ({ openRuntimeWebSocket: (url: string) => { openedRelayUrl = url; return relaySocket; } }));
|
||||||
mock.module('@/lib/runtime-auth', () => ({
|
mock.module('@/lib/runtime-auth', () => ({
|
||||||
getRuntimeBearerTokenSync: () => 'token',
|
getRuntimeBearerTokenSync: () => 'token',
|
||||||
getRuntimeExtraHeadersSync: () => ({}),
|
getRuntimeExtraHeadersSync: () => ({}),
|
||||||
|
refreshRuntimeUrlAuthToken: (baseUrl: string) => refreshUrlAuth(baseUrl),
|
||||||
}));
|
}));
|
||||||
|
mock.module('@/lib/runtime-url', () => ({ getRuntimeUrlResolver: () => ({ websocket: (path: string) => `openchamber-ui://app${path}&oc_url_token=test` }) }));
|
||||||
mock.module('@/lib/runtime-switch', () => ({
|
mock.module('@/lib/runtime-switch', () => ({
|
||||||
getRuntimeApiBaseUrl: () => apiBaseUrl,
|
getRuntimeApiBaseUrl: () => apiBaseUrl,
|
||||||
|
getRuntimeKey: () => relayActive ? 'host:exe' : `url:${apiBaseUrl}`,
|
||||||
subscribeRuntimeEndpointChanged: () => () => {},
|
subscribeRuntimeEndpointChanged: () => () => {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -25,23 +58,30 @@ const {
|
|||||||
toDisplayUrl,
|
toDisplayUrl,
|
||||||
} = await import('./devTunnel');
|
} = await import('./devTunnel');
|
||||||
|
|
||||||
const globalScope = globalThis as unknown as { window?: unknown };
|
|
||||||
|
|
||||||
const asDesktop = (value: boolean) => {
|
const asDesktop = (value: boolean) => {
|
||||||
globalScope.window = value
|
Object.defineProperty(globalThis, 'window', {
|
||||||
? { __OPENCHAMBER_ELECTRON__: true, location: { href: 'http://127.0.0.1:3901/' } }
|
configurable: true,
|
||||||
: { location: { href: 'http://127.0.0.1:3901/' } };
|
value: value
|
||||||
|
? { __OPENCHAMBER_ELECTRON__: true, location: { href: 'http://127.0.0.1:3901/' } }
|
||||||
|
: { location: { href: 'http://127.0.0.1:3901/' } },
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('loopback navigations against a remote instance', () => {
|
describe('loopback navigations against a remote instance', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiBaseUrl = 'https://remote.example.test';
|
apiBaseUrl = 'https://remote.example.test';
|
||||||
tunnelResult = { localPort: 52418, reused: false };
|
tunnelResult = { localPort: 52418, reused: false };
|
||||||
|
desktopArgs = undefined;
|
||||||
|
relayActive = false;
|
||||||
|
relayPosts.length = 0;
|
||||||
|
openedRelayUrl = '';
|
||||||
|
refreshedBaseUrl = '';
|
||||||
|
refreshUrlAuth = async (baseUrl: string) => { refreshedBaseUrl = baseUrl; return 'url-token'; };
|
||||||
asDesktop(true);
|
asDesktop(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
delete globalScope.window;
|
Reflect.deleteProperty(globalThis, 'window');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a page reached through a tunnel keeps its other ports on the host', () => {
|
test('a page reached through a tunnel keeps its other ports on the host', () => {
|
||||||
@@ -82,6 +122,39 @@ describe('loopback navigations against a remote instance', () => {
|
|||||||
expect(failed).toBe(true);
|
expect(failed).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a relay-only runtime asks Electron for a local relay bridge', async () => {
|
||||||
|
relayActive = true;
|
||||||
|
apiBaseUrl = 'openchamber-ui://app';
|
||||||
|
const resolved = await resolveBrowsableUrl('http://localhost:4322/docs/');
|
||||||
|
expect(resolved).toBe('http://127.0.0.1:52418/docs/');
|
||||||
|
expect(desktopArgs?.relay).toBe(true);
|
||||||
|
expect(desktopArgs?.targetKey).toBe('host:exe');
|
||||||
|
expect(desktopArgs?.port).toBe(4322);
|
||||||
|
|
||||||
|
relayHandler?.({ connectionId: 'connection-1', remotePort: 4322, message: { type: 'connect' } });
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
relaySocket.onopen?.();
|
||||||
|
expect(refreshedBaseUrl).toBe('openchamber-ui://app');
|
||||||
|
expect(openedRelayUrl).toContain('/api/dev-tunnel?port=4322&oc_url_token=test');
|
||||||
|
expect(relayPosts.some((entry) => entry.connectionId === 'connection-1' && entry.message.type === 'ready')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a local disconnect during auth does not leave an orphan relay socket', async () => {
|
||||||
|
relayActive = true;
|
||||||
|
apiBaseUrl = 'openchamber-ui://app';
|
||||||
|
let finishAuth = () => {};
|
||||||
|
refreshUrlAuth = () => new Promise<string>((resolve) => { finishAuth = () => resolve('url-token'); });
|
||||||
|
|
||||||
|
relayHandler?.({ connectionId: 'connection-cancelled', remotePort: 4322, message: { type: 'connect' } });
|
||||||
|
relayHandler?.({ connectionId: 'connection-cancelled', remotePort: 4322, message: { type: 'close' } });
|
||||||
|
finishAuth();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(openedRelayUrl).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
test('a local instance resolves its own loopback correctly', () => {
|
test('a local instance resolves its own loopback correctly', () => {
|
||||||
apiBaseUrl = 'http://127.0.0.1:3901';
|
apiBaseUrl = 'http://127.0.0.1:3901';
|
||||||
expect(shouldTunnelLoopbackUrl('http://localhost:4322/docs/')).toBe(false);
|
expect(shouldTunnelLoopbackUrl('http://localhost:4322/docs/')).toBe(false);
|
||||||
|
|||||||
@@ -10,17 +10,66 @@
|
|||||||
* Everywhere else — local runtime, web, mobile — the URL is already correct and
|
* Everywhere else — local runtime, web, mobile — the URL is already correct and
|
||||||
* is returned untouched.
|
* is returned untouched.
|
||||||
*/
|
*/
|
||||||
import { invokeDesktopCommand } from '@/lib/desktopNative';
|
import { invokeDesktopCommand, listenForDesktopRelayDevTunnels, postDesktopRelayDevTunnelMessage } from '@/lib/desktopNative';
|
||||||
import { getRuntimeBearerTokenSync, getRuntimeExtraHeadersSync } from '@/lib/runtime-auth';
|
import { getRuntimeBearerTokenSync, getRuntimeExtraHeadersSync, refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
|
||||||
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
import { getActiveRelayTunnel, isRelayModeActive } from '@/lib/relay/runtime-tunnel';
|
||||||
|
import { openRuntimeWebSocket } from '@/lib/relay/runtime-socket';
|
||||||
|
import type { RelayTunnelWebSocket } from '@/lib/relay/tunnel-client';
|
||||||
|
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
||||||
|
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
||||||
import { isLoopbackUrl } from './url';
|
import { isLoopbackUrl } from './url';
|
||||||
|
|
||||||
type TunnelResult = { localPort: number; reused: boolean; url: string };
|
type TunnelResult = { localPort: number };
|
||||||
|
|
||||||
/** Keyed by `${baseUrl}|${port}`; the shell owns the real lifetime. */
|
/** Keyed by `${baseUrl}|${port}`; the shell owns the real lifetime. */
|
||||||
const localPortByTarget = new Map<string, number>();
|
const localPortByTarget = new Map<string, number>();
|
||||||
/** Reverse map, so a tunnel port never leaks into the address bar or storage. */
|
/** Reverse map, so a tunnel port never leaks into the address bar or storage. */
|
||||||
const originByLocalPort = new Map<number, string>();
|
const originByLocalPort = new Map<number, string>();
|
||||||
|
const relaySockets = new Map<string, RelayTunnelWebSocket>();
|
||||||
|
const pendingRelayConnections = new Set<string>();
|
||||||
|
|
||||||
|
const openRelayConnection = async (connectionId: string, remotePort: number): Promise<void> => {
|
||||||
|
if (!getActiveRelayTunnel()) {
|
||||||
|
pendingRelayConnections.delete(connectionId);
|
||||||
|
postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl());
|
||||||
|
if (!pendingRelayConnections.has(connectionId) || !getActiveRelayTunnel()) return;
|
||||||
|
const url = getRuntimeUrlResolver().websocket(`/api/dev-tunnel?port=${remotePort}`);
|
||||||
|
const socket = openRuntimeWebSocket(url);
|
||||||
|
relaySockets.set(connectionId, socket);
|
||||||
|
socket.binaryType = 'arraybuffer';
|
||||||
|
socket.onopen = () => postDesktopRelayDevTunnelMessage(connectionId, { type: 'ready' });
|
||||||
|
socket.onmessage = (event) => postDesktopRelayDevTunnelMessage(connectionId, { type: 'data', data: event.data instanceof ArrayBuffer ? event.data : new TextEncoder().encode(event.data) });
|
||||||
|
socket.onerror = () => postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' });
|
||||||
|
socket.onclose = () => {
|
||||||
|
pendingRelayConnections.delete(connectionId);
|
||||||
|
relaySockets.delete(connectionId);
|
||||||
|
postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
listenForDesktopRelayDevTunnels(({ connectionId, remotePort, message }) => {
|
||||||
|
switch (message.type) {
|
||||||
|
case 'data': {
|
||||||
|
const socket = relaySockets.get(connectionId);
|
||||||
|
if (socket && message.data) socket.send(message.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'close':
|
||||||
|
pendingRelayConnections.delete(connectionId);
|
||||||
|
relaySockets.get(connectionId)?.close();
|
||||||
|
relaySockets.delete(connectionId);
|
||||||
|
return;
|
||||||
|
case 'connect':
|
||||||
|
pendingRelayConnections.add(connectionId);
|
||||||
|
void openRelayConnection(connectionId, remotePort).catch(() => {
|
||||||
|
pendingRelayConnections.delete(connectionId);
|
||||||
|
postDesktopRelayDevTunnelMessage(connectionId, { type: 'close' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const isDesktopRuntime = (): boolean => (
|
const isDesktopRuntime = (): boolean => (
|
||||||
typeof window !== 'undefined' && Boolean(window.__OPENCHAMBER_ELECTRON__)
|
typeof window !== 'undefined' && Boolean(window.__OPENCHAMBER_ELECTRON__)
|
||||||
@@ -69,6 +118,14 @@ const rewriteToLocalPort = (url: string, localPort: number): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rememberOriginalOrigin = (url: string, localPort: number): void => {
|
||||||
|
try {
|
||||||
|
originByLocalPort.set(localPort, new URL(url).origin);
|
||||||
|
} catch {
|
||||||
|
// Unparseable input never reaches here; nothing to record.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** Thrown when a remote dev server exists but could not be reached from here. */
|
/** Thrown when a remote dev server exists but could not be reached from here. */
|
||||||
export class DevTunnelUnavailableError extends Error {
|
export class DevTunnelUnavailableError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
@@ -100,11 +157,7 @@ export const resolveBrowsableUrl = async (url: string): Promise<string> => {
|
|||||||
const key = `${baseUrl}|${port}`;
|
const key = `${baseUrl}|${port}`;
|
||||||
const cached = localPortByTarget.get(key);
|
const cached = localPortByTarget.get(key);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
rememberOriginalOrigin(url, cached);
|
||||||
originByLocalPort.set(cached, new URL(url).origin);
|
|
||||||
} catch {
|
|
||||||
// Unparseable input never reaches here; nothing to record.
|
|
||||||
}
|
|
||||||
return rewriteToLocalPort(url, cached);
|
return rewriteToLocalPort(url, cached);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +165,8 @@ export const resolveBrowsableUrl = async (url: string): Promise<string> => {
|
|||||||
const result = await invokeDesktopCommand<TunnelResult>('desktop_dev_tunnel_open', {
|
const result = await invokeDesktopCommand<TunnelResult>('desktop_dev_tunnel_open', {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
port,
|
port,
|
||||||
|
relay: isRelayModeActive(),
|
||||||
|
targetKey: getRuntimeKey(),
|
||||||
clientToken: getRuntimeBearerTokenSync(),
|
clientToken: getRuntimeBearerTokenSync(),
|
||||||
requestHeaders: getRuntimeExtraHeadersSync(),
|
requestHeaders: getRuntimeExtraHeadersSync(),
|
||||||
});
|
});
|
||||||
@@ -119,11 +174,7 @@ export const resolveBrowsableUrl = async (url: string): Promise<string> => {
|
|||||||
throw new DevTunnelUnavailableError(url);
|
throw new DevTunnelUnavailableError(url);
|
||||||
}
|
}
|
||||||
localPortByTarget.set(key, result.localPort);
|
localPortByTarget.set(key, result.localPort);
|
||||||
try {
|
rememberOriginalOrigin(url, result.localPort);
|
||||||
originByLocalPort.set(result.localPort, new URL(url).origin);
|
|
||||||
} catch {
|
|
||||||
// Unparseable input never reaches here; nothing to record.
|
|
||||||
}
|
|
||||||
return rewriteToLocalPort(url, result.localPort);
|
return rewriteToLocalPort(url, result.localPort);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DevTunnelUnavailableError) throw error;
|
if (error instanceof DevTunnelUnavailableError) throw error;
|
||||||
@@ -180,6 +231,10 @@ export const toDisplayUrl = (url: string): string => {
|
|||||||
const resetDevTunnelCache = (): void => {
|
const resetDevTunnelCache = (): void => {
|
||||||
localPortByTarget.clear();
|
localPortByTarget.clear();
|
||||||
originByLocalPort.clear();
|
originByLocalPort.clear();
|
||||||
|
pendingRelayConnections.clear();
|
||||||
|
for (const socket of relaySockets.values()) socket.close();
|
||||||
|
relaySockets.clear();
|
||||||
|
void invokeDesktopCommand('desktop_relay_dev_tunnel_close_all').catch(() => {});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
|||||||
@@ -1,6 +1,34 @@
|
|||||||
import { hasDesktopInvoke, invokeDesktop, isDesktopShell } from '@/lib/desktop';
|
import { hasDesktopInvoke, invokeDesktop, isDesktopShell } from '@/lib/desktop';
|
||||||
|
|
||||||
type InvokeArgs = Record<string, unknown>;
|
type InvokeArgs = Record<string, unknown>;
|
||||||
|
type RelayDevTunnelData = ArrayBuffer | Uint8Array;
|
||||||
|
type RelayDevTunnelMessage = { type: 'connect' | 'ready' | 'data' | 'close'; data?: RelayDevTunnelData };
|
||||||
|
type RelayDevTunnelEvent = { connectionId: string; remotePort: number; message: RelayDevTunnelMessage };
|
||||||
|
type RelayDevTunnelBridge = {
|
||||||
|
relayDevTunnelListen?: (handler: (event: RelayDevTunnelEvent) => void) => void;
|
||||||
|
relayDevTunnelPost?: (connectionId: string, message: RelayDevTunnelMessage) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__OPENCHAMBER_DESKTOP__?: RelayDevTunnelBridge;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRelayDevTunnelBridge = (): RelayDevTunnelBridge | null => {
|
||||||
|
return globalThis.window?.__OPENCHAMBER_DESKTOP__ ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const listenForDesktopRelayDevTunnels = (handler: (event: RelayDevTunnelEvent) => void): boolean => {
|
||||||
|
const bridge = getRelayDevTunnelBridge();
|
||||||
|
if (!bridge?.relayDevTunnelListen) return false;
|
||||||
|
bridge.relayDevTunnelListen(handler);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const postDesktopRelayDevTunnelMessage = (connectionId: string, message: RelayDevTunnelMessage): void => {
|
||||||
|
getRelayDevTunnelBridge()?.relayDevTunnelPost?.(connectionId, message);
|
||||||
|
};
|
||||||
|
|
||||||
export const invokeDesktopCommand = async <TValue = unknown>(
|
export const invokeDesktopCommand = async <TValue = unknown>(
|
||||||
command: string,
|
command: string,
|
||||||
|
|||||||
@@ -20,7 +20,13 @@ and what made it fragile per framework.
|
|||||||
- `client.js` is the local end: it binds a loopback listener on the user's
|
- `client.js` is the local end: it binds a loopback listener on the user's
|
||||||
machine and pipes each accepted connection through one WebSocket. It lives in
|
machine and pipes each accepted connection through one WebSocket. It lives in
|
||||||
this package because it needs a WebSocket client the package already depends
|
this package because it needs a WebSocket client the package already depends
|
||||||
on; the desktop shell drives it over IPC.
|
on; the desktop shell drives it over IPC for directly reachable HTTP(S)
|
||||||
|
runtimes.
|
||||||
|
- Relay-only runtimes use `packages/electron/relay-dev-tunnel.mjs` for the local
|
||||||
|
listener. Each accepted connection gets an Electron `MessagePort`; the trusted
|
||||||
|
renderer carries its bytes through the active E2EE relay. This keeps relay
|
||||||
|
credentials and encryption in their existing renderer owner instead of
|
||||||
|
duplicating them in Electron main.
|
||||||
- Port discovery is not owned here. `runtime.js` is given the reachable set by
|
- Port discovery is not owned here. `runtime.js` is given the reachable set by
|
||||||
the same dev-server discovery the user's own list is built from.
|
the same dev-server discovery the user's own list is built from.
|
||||||
- The browser panel decides when to tunnel; this module never chooses a target.
|
- The browser panel decides when to tunnel; this module never chooses a target.
|
||||||
@@ -40,9 +46,12 @@ and what made it fragile per framework.
|
|||||||
usual origin allowlist applies unchanged. That check is a CSRF defence: a
|
usual origin allowlist applies unchanged. That check is a CSRF defence: a
|
||||||
hostile page can make a browser open a WebSocket carrying ambient cookies,
|
hostile page can make a browser open a WebSocket carrying ambient cookies,
|
||||||
and the origin is what exposes it.
|
and the origin is what exposes it.
|
||||||
- With no `Origin` the request must carry client-token auth. A browser cannot
|
- With no `Origin` the request must carry client-token auth or a short-lived
|
||||||
reach this path — the WebSocket API always sends an origin and never lets a
|
URL token. The bearer case is the desktop main process. The URL-token case
|
||||||
page set an `Authorization` header — so this case is the desktop shell.
|
is the trusted renderer carrying the socket through the E2EE relay.
|
||||||
|
- Through the E2EE relay, the trusted renderer mints a short-lived URL token
|
||||||
|
and includes it in the virtual WebSocket URL. The relay host and URL-token
|
||||||
|
allowlists accept exactly `/api/dev-tunnel`, not subpaths.
|
||||||
- Concurrency is capped per host, not per page, because one page load opens
|
- Concurrency is capped per host, not per page, because one page load opens
|
||||||
many sockets.
|
many sockets.
|
||||||
- A connection that cannot be established fails the socket rather than holding
|
- A connection that cannot be established fails the socket rather than holding
|
||||||
|
|||||||
@@ -20,9 +20,9 @@
|
|||||||
*
|
*
|
||||||
* - With an `Origin` header, the request came from a browser context and the
|
* - With an `Origin` header, the request came from a browser context and the
|
||||||
* usual origin check applies unchanged.
|
* usual origin check applies unchanged.
|
||||||
* - With no `Origin`, the request must carry client-token auth. A browser
|
* - With no `Origin`, the request must carry client-token auth or a short-lived
|
||||||
* cannot reach this path: the WebSocket API always sends an origin and never
|
* URL token. The URL-token case is used only by the trusted renderer through
|
||||||
* lets a page set an `Authorization` header.
|
* the E2EE relay; the UI-auth allowlist limits it to this exact path.
|
||||||
*/
|
*/
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
@@ -133,7 +133,7 @@ export function createDevTunnelRuntime({
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
if (uiAuthController?.enabled) {
|
if (uiAuthController?.enabled) {
|
||||||
const auth = await uiAuthController.resolveAuthContext(req, null, { allowUrlToken: false });
|
const auth = await uiAuthController.resolveAuthContext(req, null, { allowUrlToken: true });
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
|
rejectWebSocketUpgrade(socket, 401, 'UI authentication required');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, describe, expect, test } from 'bun:test';
|
import { afterEach, describe, expect, test } from 'bun:test';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
|
||||||
import { createDevTunnelClient } from './client.js';
|
import { createDevTunnelClient } from './client.js';
|
||||||
import { createDevTunnelRuntime, isDevTunnelPath } from './runtime.js';
|
import { createDevTunnelRuntime, isDevTunnelPath } from './runtime.js';
|
||||||
@@ -228,6 +229,12 @@ describe('dev tunnel authentication', () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
resolveAuthContext: async () => ({ type: 'session' }),
|
resolveAuthContext: async () => ({ type: 'session' }),
|
||||||
};
|
};
|
||||||
|
const urlTokenAuth = {
|
||||||
|
enabled: true,
|
||||||
|
resolveAuthContext: async (req, _res, options) => (
|
||||||
|
options?.allowUrlToken === true && req.url.includes('oc_url_token=good') ? { type: 'client', token: 'url:authenticated' } : null
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
test('accepts a bearer-authenticated client that sends no origin', async () => {
|
test('accepts a bearer-authenticated client that sends no origin', async () => {
|
||||||
const devPort = await startDevServer((_req, res) => res.end('ok'));
|
const devPort = await startDevServer((_req, res) => res.end('ok'));
|
||||||
@@ -243,6 +250,19 @@ describe('dev tunnel authentication', () => {
|
|||||||
expect((await httpGet(localPort, '/')).body).toBe('ok');
|
expect((await httpGet(localPort, '/')).body).toBe('ok');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('accepts a URL-token client carried by the E2EE relay', async () => {
|
||||||
|
const devPort = await startDevServer((_req, res) => res.end('relay-ok'));
|
||||||
|
const host = await startHost({ allowedPorts: [devPort], auth: urlTokenAuth });
|
||||||
|
|
||||||
|
const body = await new Promise((resolve, reject) => {
|
||||||
|
const socket = new WebSocket(`ws://127.0.0.1:${host.port}/api/dev-tunnel?port=${devPort}&oc_url_token=good`);
|
||||||
|
socket.on('open', () => socket.send('GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n'));
|
||||||
|
socket.on('message', (data) => resolve(Buffer.from(data).toString()));
|
||||||
|
socket.on('error', reject);
|
||||||
|
});
|
||||||
|
expect(body).toContain('relay-ok');
|
||||||
|
});
|
||||||
|
|
||||||
test('rejects a client with no credentials', async () => {
|
test('rejects a client with no credentials', async () => {
|
||||||
const devPort = await startDevServer((_req, res) => res.end('ok'));
|
const devPort = await startDevServer((_req, res) => res.end('ok'));
|
||||||
const host = await startHost({ allowedPorts: [devPort], auth: clientAuth });
|
const host = await startHost({ allowedPorts: [devPort], auth: clientAuth });
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ Relay is not a separate link format: it is one transport candidate inside the un
|
|||||||
Everything a client normally sends to the single OpenChamber origin:
|
Everything a client normally sends to the single OpenChamber origin:
|
||||||
- **HTTP** — REST endpoints and proxied OpenCode SDK calls under `/api/*`, plus `/auth/*` and `/health`.
|
- **HTTP** — REST endpoints and proxied OpenCode SDK calls under `/api/*`, plus `/auth/*` and `/health`.
|
||||||
- **SSE** — long-lived streamed responses (the event stream and notifications). These are just HTTP responses whose body streams; the tunnel needs no special SSE handling.
|
- **SSE** — long-lived streamed responses (the event stream and notifications). These are just HTTP responses whose body streams; the tunnel needs no special SSE handling.
|
||||||
- **WebSocket** — the endpoints that use a real socket (the global event stream on platforms that support WS, terminal I/O, dictation).
|
- **WebSocket** — the endpoints that use a real socket (the global event stream on platforms that support WS, terminal I/O, dictation, and desktop dev-server previews).
|
||||||
|
|
||||||
The host dispatcher restricts tunneled traffic to explicit path allowlists (one for HTTP, one for WS).
|
The host dispatcher restricts tunneled traffic to explicit path allowlists (one for HTTP, one for WS).
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ const ALLOWED_WS_PATHS = new Set([
|
|||||||
'/api/event/ws',
|
'/api/event/ws',
|
||||||
'/api/terminal/ws',
|
'/api/terminal/ws',
|
||||||
'/api/dictation/ws',
|
'/api/dictation/ws',
|
||||||
|
'/api/dev-tunnel',
|
||||||
]);
|
]);
|
||||||
|
export const isAllowedRelayWebSocketPath = (pathname) => ALLOWED_WS_PATHS.has(pathname);
|
||||||
|
|
||||||
// Hop-by-hop headers stripped from tunneled requests; `host` is set by fetch
|
// Hop-by-hop headers stripped from tunneled requests; `host` is set by fetch
|
||||||
// to the loopback origin. content-length is dropped too because the body is
|
// to the loopback origin. content-length is dropped too because the body is
|
||||||
@@ -425,7 +427,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
|
|||||||
void sendAbort(streamId, error?.message ?? 'malformed ws open');
|
void sendAbort(streamId, error?.message ?? 'malformed ws open');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!ALLOWED_WS_PATHS.has(open.path)) {
|
if (!isAllowedRelayWebSocketPath(open.path)) {
|
||||||
void sendAbort(streamId, 'Path is not allowed through the relay');
|
void sendAbort(streamId, 'Path is not allowed through the relay');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, test, expect } from 'bun:test';
|
import { describe, test, expect } from 'bun:test';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
|
|
||||||
import { createTunnelHost } from './tunnel-host.js';
|
import { createTunnelHost, isAllowedRelayWebSocketPath } from './tunnel-host.js';
|
||||||
import { decodeTunnelFrame, encodeTunnelFrame, encodeJsonPayload, TunnelFrameType } from './tunnel-codec.js';
|
import { decodeTunnelFrame, encodeTunnelFrame, encodeJsonPayload, TunnelFrameType } from './tunnel-codec.js';
|
||||||
|
|
||||||
const startLoopback = () =>
|
const startLoopback = () =>
|
||||||
@@ -145,3 +145,11 @@ describe('tunnel-host HTTP body forwarding', () => {
|
|||||||
await loopback.stop();
|
await loopback.stop();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('relay host WebSocket allowlist', () => {
|
||||||
|
test('allows only the exact dev-server tunnel path', () => {
|
||||||
|
expect(isAllowedRelayWebSocketPath('/api/dev-tunnel')).toBe(true);
|
||||||
|
expect(isAllowedRelayWebSocketPath('/api/dev-tunnel/')).toBe(false);
|
||||||
|
expect(isAllowedRelayWebSocketPath('/api/database/ws')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ const isUrlAuthWebSocketPath = (pathname) => {
|
|||||||
|| pathname === '/api/openchamber/realtime-proxy/ws'
|
|| pathname === '/api/openchamber/realtime-proxy/ws'
|
||||||
|| pathname === '/api/terminal/ws'
|
|| pathname === '/api/terminal/ws'
|
||||||
|| pathname === '/api/dictation/ws'
|
|| pathname === '/api/dictation/ws'
|
||||||
|
|| pathname === '/api/dev-tunnel'
|
||||||
|| pathname.startsWith('/api/preview/proxy/');
|
|| pathname.startsWith('/api/preview/proxy/');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -221,6 +221,22 @@ describe('ui auth client credential seam', () => {
|
|||||||
};
|
};
|
||||||
expect(await auth.ensureSessionToken(dictationWsReq, null)).toBe('client:device-1');
|
expect(await auth.ensureSessionToken(dictationWsReq, null)).toBe('client:device-1');
|
||||||
|
|
||||||
|
const devTunnelWsReq = {
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/dev-tunnel',
|
||||||
|
url: `/api/dev-tunnel?port=4322&oc_url_token=${encodeURIComponent(urlToken)}`,
|
||||||
|
headers: { upgrade: 'websocket' },
|
||||||
|
};
|
||||||
|
expect(await auth.ensureSessionToken(devTunnelWsReq, null)).toBe('client:device-1');
|
||||||
|
|
||||||
|
const devTunnelSubpathWsReq = {
|
||||||
|
method: 'GET',
|
||||||
|
path: '/api/dev-tunnel/private',
|
||||||
|
url: `/api/dev-tunnel/private?port=4322&oc_url_token=${encodeURIComponent(urlToken)}`,
|
||||||
|
headers: { upgrade: 'websocket' },
|
||||||
|
};
|
||||||
|
expect(await auth.ensureSessionToken(devTunnelSubpathWsReq, null)).toBe(null);
|
||||||
|
|
||||||
const dictationHttpReq = {
|
const dictationHttpReq = {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/api/dictation/ws',
|
path: '/api/dictation/ws',
|
||||||
|
|||||||
Reference in New Issue
Block a user