From fd22d8bde1386435166415484021b6f5d9035b81 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 7 Dec 2022 08:46:35 -0800 Subject: [PATCH] feat(tether): support hostname pattern in socks proxy (#19302) For now, only '*' and 'localhost' are supported. References #19287. --- .../playwright-core/src/common/socksProxy.ts | 48 +++++++++++++++++++ .../playwright-core/src/containers/docker.ts | 1 + .../src/grid/gridBrowserWorker.ts | 2 +- .../src/remote/playwrightConnection.ts | 6 ++- .../src/remote/playwrightServer.ts | 4 +- tests/library/port-forwarding-server.spec.ts | 40 ++++++++++++++-- 6 files changed, 92 insertions(+), 9 deletions(-) diff --git a/packages/playwright-core/src/common/socksProxy.ts b/packages/playwright-core/src/common/socksProxy.ts index 166234468e..048a19bc89 100644 --- a/packages/playwright-core/src/common/socksProxy.ts +++ b/packages/playwright-core/src/common/socksProxy.ts @@ -297,6 +297,8 @@ export class SocksProxy extends EventEmitter implements SocksConnectionClient { private _sockets = new Set(); private _closed = false; private _port: number | undefined; + private _pattern: string | undefined; + private _directSockets = new Map(); constructor() { super(); @@ -315,6 +317,37 @@ export class SocksProxy extends EventEmitter implements SocksConnectionClient { }); } + setProxyPattern(pattern: string | undefined) { + this._pattern = pattern; + } + + private _matchesPattern(request: SocksSocketRequestedPayload) { + return this._pattern === '*' || (this._pattern === 'localhost' && request.host === 'localhost'); + } + + private async _handleDirect(request: SocksSocketRequestedPayload) { + try { + // TODO: Node.js 17 does resolve localhost to ipv6 + const { address } = await dnsLookupAsync(request.host === 'localhost' ? '127.0.0.1' : request.host); + const socket = await createSocket(address, request.port); + socket.on('data', data => this._connections.get(request.uid)?.sendData(data)); + socket.on('error', error => { + this._connections.get(request.uid)?.error(error.message); + this._directSockets.delete(request.uid); + }); + socket.on('end', () => { + this._connections.get(request.uid)?.end(); + this._directSockets.delete(request.uid); + }); + const localAddress = socket.localAddress; + const localPort = socket.localPort; + this._directSockets.set(request.uid, socket); + this._connections.get(request.uid)?.socketConnected(localAddress, localPort); + } catch (error) { + this._connections.get(request.uid)?.socketFailed(error.code); + } + } + port() { return this._port; } @@ -339,14 +372,29 @@ export class SocksProxy extends EventEmitter implements SocksConnectionClient { } onSocketRequested(payload: SocksSocketRequestedPayload) { + if (!this._matchesPattern(payload)) { + this._handleDirect(payload); + return; + } this.emit(SocksProxy.Events.SocksRequested, payload); } onSocketData(payload: SocksSocketDataPayload): void { + const direct = this._directSockets.get(payload.uid); + if (direct) { + direct.write(payload.data); + return; + } this.emit(SocksProxy.Events.SocksData, payload); } onSocketClosed(payload: SocksSocketClosedPayload): void { + const direct = this._directSockets.get(payload.uid); + if (direct) { + direct.destroy(); + this._directSockets.delete(payload.uid); + return; + } this.emit(SocksProxy.Events.SocksClosed, payload); } diff --git a/packages/playwright-core/src/containers/docker.ts b/packages/playwright-core/src/containers/docker.ts index 1d5b927d55..a65fcfd795 100644 --- a/packages/playwright-core/src/containers/docker.ts +++ b/packages/playwright-core/src/containers/docker.ts @@ -299,6 +299,7 @@ async function tetherHostNetwork(endpoint: string) { const headers: any = { 'User-Agent': getUserAgent(), 'x-playwright-network-tethering': '1', + 'x-playwright-proxy': '*', }; const transport = await WebSocketTransport.connect(undefined /* progress */, wsEndpoint, headers, true /* followRedirects */); const socksInterceptor = new SocksInterceptor(transport, undefined); diff --git a/packages/playwright-core/src/grid/gridBrowserWorker.ts b/packages/playwright-core/src/grid/gridBrowserWorker.ts index 23a9f5c9bf..c424d216e7 100644 --- a/packages/playwright-core/src/grid/gridBrowserWorker.ts +++ b/packages/playwright-core/src/grid/gridBrowserWorker.ts @@ -23,7 +23,7 @@ function launchGridBrowserWorker(gridURL: string, agentId: string, workerId: str const log = debug(`pw:grid:worker:${workerId}`); log('created'); const ws = new WebSocket(gridURL.replace('http://', 'ws://') + `/registerWorker?agentId=${agentId}&workerId=${workerId}`); - new PlaywrightConnection(Promise.resolve(), 'launch-browser', ws, { enableSocksProxy: true, browserName, launchOptions: {} }, { }, log, async () => { + new PlaywrightConnection(Promise.resolve(), 'launch-browser', ws, { socksProxy: '*', browserName, launchOptions: {} }, { }, log, async () => { log('exiting process'); setTimeout(() => process.exit(0), 30000); // Meanwhile, try to gracefully close all browsers. diff --git a/packages/playwright-core/src/remote/playwrightConnection.ts b/packages/playwright-core/src/remote/playwrightConnection.ts index f223362b44..202c644e32 100644 --- a/packages/playwright-core/src/remote/playwrightConnection.ts +++ b/packages/playwright-core/src/remote/playwrightConnection.ts @@ -28,7 +28,7 @@ import { DebugControllerDispatcher } from '../server/dispatchers/debugController export type ClientType = 'controller' | 'playwright' | 'launch-browser' | 'reuse-browser' | 'pre-launched-browser' | 'network-tethering'; type Options = { - enableSocksProxy: boolean, + socksProxy: string | undefined, browserName: string | null, launchOptions: LaunchOptions, }; @@ -99,6 +99,7 @@ export class PlaywrightConnection { private async _initPlaywrightTetheringMode(scope: RootDispatcher) { this._debugLog(`engaged playwright.tethering mode`); const playwright = createPlaywright('javascript'); + this._preLaunched.networkTetheringSocksProxy?.setProxyPattern(this._options.socksProxy); return new PlaywrightDispatcher(scope, playwright, this._preLaunched.networkTetheringSocksProxy); } @@ -221,7 +222,7 @@ export class PlaywrightConnection { } private async _configureSocksProxy(playwright: Playwright): Promise { - if (!this._options.enableSocksProxy) + if (!this._options.socksProxy) return undefined; if (this._preLaunched.networkTetheringSocksProxy) { playwright.options.socksProxyPort = this._preLaunched.networkTetheringSocksProxy.port(); @@ -229,6 +230,7 @@ export class PlaywrightConnection { return undefined; } const socksProxy = new SocksProxy(); + socksProxy.setProxyPattern(this._options.socksProxy); playwright.options.socksProxyPort = await socksProxy.listen(0); this._debugLog(`started socks proxy on port ${playwright.options.socksProxyPort}`); this._cleanups.push(() => socksProxy.close()); diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index 16cbd95796..2ac7ee9866 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -115,7 +115,7 @@ export class PlaywrightServer { const browserName = url.searchParams.get('browser') || (Array.isArray(browserHeader) ? browserHeader[0] : browserHeader) || null; const proxyHeader = request.headers['x-playwright-proxy']; const proxyValue = url.searchParams.get('proxy') || (Array.isArray(proxyHeader) ? proxyHeader[0] : proxyHeader); - const enableSocksProxy = this._options.browserProxyMode !== 'disabled' && proxyValue === '*'; + const socksProxy = this._options.browserProxyMode !== 'disabled' ? proxyValue : undefined; const launchOptionsHeader = request.headers['x-playwright-launch-options'] || ''; let launchOptions: LaunchOptions = {}; @@ -160,7 +160,7 @@ export class PlaywrightServer { const connection = new PlaywrightConnection( semaphore.aquire(), clientType, ws, - { enableSocksProxy, browserName, launchOptions }, + { socksProxy, browserName, launchOptions }, { playwright: this._preLaunchedPlaywright, browser: this._options.preLaunchedBrowser, diff --git a/tests/library/port-forwarding-server.spec.ts b/tests/library/port-forwarding-server.spec.ts index 43dfd2789b..6950f04344 100644 --- a/tests/library/port-forwarding-server.spec.ts +++ b/tests/library/port-forwarding-server.spec.ts @@ -58,15 +58,15 @@ class OutOfProcessPlaywrightServer { } } -const it = contextTest.extend<{ pageFactory: (redirectPortForTest?: number) => Promise }>({ +const it = contextTest.extend<{ pageFactory: (redirectPortForTest?: number, pattern?: string) => Promise }>({ pageFactory: async ({ browserType, browserName, channel }, run, testInfo) => { const playwrightServers: OutOfProcessPlaywrightServer[] = []; const browsers: Browser[] = []; - await run(async (redirectPortForTest?: number): Promise => { + await run(async (redirectPortForTest?: number, pattern = '*'): Promise => { const server = new OutOfProcessPlaywrightServer(0, 3200 + testInfo.workerIndex); playwrightServers.push(server); const browser = await browserType.connect({ - wsEndpoint: await server.wsEndpoint() + '?proxy=*&browser=' + browserName, + wsEndpoint: await server.wsEndpoint() + `?proxy=${pattern}&browser=` + browserName, headers: { 'x-playwright-launch-options': JSON.stringify({ channel }) }, __testHookRedirectPortForwarding: redirectPortForTest, } as any); @@ -166,5 +166,37 @@ it('should lead to the error page for forwarded requests when the connection is else if (browserName === 'webkit') expect(error.message).toBeTruthy(); else if (browserName === 'firefox') - expect(error.message.includes('NS_ERROR_NET_RESET') || error.message.includes('NS_ERROR_CONNECTION_REFUSED')).toBe(true); + expect(error.message.includes('NS_ERROR_NET_RESET') || error.message.includes('NS_ERROR_CONNECTION_REFUSED')).toBe(true); +}); + +it('should proxy based on the pattern', async ({ pageFactory, server, browserName, platform }, workerInfo) => { + it.skip(browserName === 'webkit' && platform === 'darwin'); + + const { testServerPort, stopTestServer } = await startTestServer(); + let reachedOriginalTarget = false; + server.setRoute('/foo.html', async (req, res) => { + reachedOriginalTarget = true; + res.end('from-original-server'); + }); + const examplePort = 20_000 + workerInfo.workerIndex * 3; + const page = await pageFactory(testServerPort, 'localhost'); + + // localhost should be proxied. + await page.goto(`http://localhost:${examplePort}/foo.html`); + expect(await page.content()).toContain('from-retargeted-server'); + expect(reachedOriginalTarget).toBe(false); + + // 127.0.0.1 should be served directly. + await page.goto(`http://127.0.0.1:${server.PORT}/foo.html`); + expect(await page.content()).toContain('from-original-server'); + expect(reachedOriginalTarget).toBe(true); + + // Random domain should be served directly and fail. + let failed = false; + await page.goto(`http://does-not-exist-bad-domain.oh-no-should-not-work`).catch(e => { + failed = true; + }); + expect(failed).toBe(true); + + stopTestServer(); });